Windows Phone 7 のはてなフォトライフビューワを作る (前編)
先日すまべん特別編で飛び込みでやった実習を、再度やってみたい人のためにリプレイです。みんな知ってる日本のサービスで、使いやすい物を探してみると、はてなフォトライフが使いやすかったのでコレを題材にします。
前編の実装を僕が、後編のデザインを id:c-mitsuba がやってくれます。
Windows Phone 7 開発のおいしいところを体感できます。Windows Phone Developer Tools片手にあなたもレッツトライ!
ソリューションファイルを作る
Microsoft Visual Studio 2010 Express Windows Phone を起動して、File→New→Project を選びます。
Panoramaの方がいい気もしますが、今回はPivotを使ってみます。Windows Phone Pivot Application を選択して FotolifeViewと名付けて作成します。
Expression Blendに移り、デザインを作る
プロジェクト右クリックから、Open in Expression Blend... を選ぶと、Expression Blend が起動してきます。
Blendの初期画面はこんな感じです。
では、まずPivotに表示させる項目を考えます。はてなフォトライフから写真を取得できる代表的なRSSは以下の3つです。
- 最新の写真
- 人気の写真
- ユーザーの写真
このうち、僕のWP7 UI研究結果によると「新しいもの、ユーザーが見たいと思うものが左にあるべき」なのでPivotの項目は左からnew hot id:iseebi とすることにします。(id:iseebi のところは自分のIDで読み替えてください)
初期状態のPivotのページは2つしかありません。1こ増やしましょう。デザイナ上から選ぶこともできますが、「オブジェクトとタイムライン」のウィンドウから選ぶ方が確実です。Pivot を右クリックして Add PitotItem を選択します。
いっこ増えました
グリッドしかないので、リストがほしくなります。デフォルトであったPivotItemからコピペしてきましょう。
- 新しく追加したPivotItemにあるGridを選択して、右クリック→削除
- 2つめのPivotItemにあるSecondListBoxを選択して、右クリック→コピー
- 新しく追加したPivotItemを選択して、右クリック→貼り付け
こんな感じになります。
次に、Pivotのタイトルを変更していきます。「オブジェクトとタイムライン」から1つめのPivotItemを選びます。「プロパティ」のウィンドウの「共通プロパティ」というところに「Header」というのがあります。ここを「new」と書き換えます。
同じように、2つめを hot、3つめを id:iseebi に書き換えます。
そして、リストボックスの名前を変えましょう。「オブジェクトとタイムライン」からFirstListBoxを選択、右クリックから名前変更を選択してnewPhotoListBoxに変更します。同様にSecondListBoxをhotPhotoListBox、SecondListBox_CopyをuserPhotoListBoxにします。
サンプルデータソースを作っていい感じにする
次は実際にデータを入れたときにどのような画面になるかを作っていきます。「データ」ウィンドウを開き、右上から2つめのアイコンをクリックして、「新しいサンプルデータ...」を選択します。
そのままOKを押します。
新しいデータソースが追加されます。
ここに、今回表示するプロパティに名前を変更します。これは、このあと作る項目ごとのクラスの名前と合わせておく必要があります。一回選択して、しばらくしてからもう1回クリックすると名前を変更できます。
- ImageUrlSmall
- サムネイル画像のURL
- 型:イメージ
- Title
- 写真のタイトル
- 型:文字列
型は右側のボタンを選択すると選ぶことができます。
ここまででこんな感じです。
では、このリソースを関連づけます。SampleDataSourceの中にあるCollectionを選択して、Pivotの中にあるListBoxへドロップします。
デフォルトの写真がセットされたリストに変化します。
残りの2つのリストにもドラッグしておきます。これでひとまずデザインは完了です。
Visual Studioに戻るために、編集を保存してBlendを終了させます。終了させずに戻ることができる場合もありますが、共有例外が出ることが多々あるので終了させることをおすすめしています。以下のダイアログがされるので、Reloadを押して再読込します。
はてなフォトライフからデータを取得するプログラムを書く
ソリューションエクスプローラから MainPage.xaml.cs を開いてください。こんな感じになってると思います。
public partial class MainPage : PhoneApplicationPage { // Constructor public MainPage() { InitializeComponent(); // Set the data context of the listbox control to the sample data DataContext = App.ViewModel; this.Loaded += new RoutedEventHandler(MainPage_Loaded); } // Load data for the ViewModel Items private void MainPage_Loaded(object sender, RoutedEventArgs e) { if (!App.ViewModel.IsDataLoaded) { App.ViewModel.LoadData(); } } }
はてなフォトライフからデータを持ってくる処理は僕が以前書いたことがあり、Gistに書いてあるのでコピペしてきます。
https://gist.github.com/492486
これはLINQPad用の書き殴りなので、使いやすいようにメソッドにしようと思います。まず、結果が匿名型になっていてバインディングできないので、結果を格納するためのクラスを作ります。プロジェクト右クリック→Add→New Item です。
Classを選んでHatenaFotolifeRssItem.csと名前をつけてAddをクリックします。
新しいファイルができるので、元のソースに書いてあった戻り値を全部パブリックプロパティにします。
public class HatenaFotolifeRssItem { public string Title { get; set; } public string Link { get; set; } public string Description { get; set; } public string Content { get; set; } public string Date { get; set; } public string ImageUrl { get; set; } public string ImageUrlSmall { get; set; } public string ImageUrlMedium { get; set; } public string Syntax { get; set; } public string[] Colors { get; set; } }
できたら、MainPage.xaml.cs へ戻ります。Gistのソースから const の4行をフィールドとしてコピペします。次に、XDocument docを宣言しているところから、次のLINQまでをコピペして、以下のようなメソッドを作ります。
select new で匿名型を作っているところは、 HatenaFotolifeRssItem() を書き足して先ほど作ったクラスが作られるように変更します。また、最後のColorsのところは匿名型から通常の型にした影響でキャストが必要になっていますので、コレも追加しています。
さらに、XDocumentの読み込み先URLもパラメータにしました。
public void LoadImageListBox(string url, ListBox targetList) { XDocument doc = XDocument.Load(url); var q = from e in doc.Root.Elements() where e.Name.ToString().EndsWith("item") select new HatenaFotolifeRssItem() { Title = e.Element(rssSpace + "title").Value, Link = e.Element(rssSpace + "link").Value, Description = e.Element(rssSpace + "description").Value, Content = e.Element(contentSpace + "encoded").Value, Date = e.Element(dcSpace + "date").Value, ImageUrl = e.Element(hatenaSpace + "imageurl").Value, ImageUrlSmall = e.Element(hatenaSpace + "imageurlsmall").Value, ImageUrlMedium = e.Element(hatenaSpace + "imageurlmedium").Value, Syntax = e.Element(hatenaSpace + "syntax").Value, //Colors = from c in e.Element(hatenaSpace + "colors").Elements() where c.Name == hatenaSpace + "color" select c.Value Colors = (from c in e.Element(hatenaSpace + "colors").Elements() where c.Name == hatenaSpace + "color" select c.Value).ToArray<string>() }; }
おっと、XDocument に赤線が出ています。アセンブリの参照が足りていません。
プロジェクトのReferencesのところを右クリック、Add References...です。
System.Xml.Linq を選び、OKを押します。
参照追加してもまだ赤線が消えませんが、スマートタグが出るようになっています。using System.Xml.Linq; を選びます。先頭行にusingが追加されて問題が解決されます。
次に、コンストラクタにデザインで作ったListBoxに先ほど示したRSSのURLに合わせて画像を読み込むようにメソッドの呼び出しを書きます。
// 画像の読み込み LoadImageListBox("http://f.hatena.ne.jp/userlist?mode=rss", newPhotoListBox); LoadImageListBox("http://f.hatena.ne.jp/hotfoto?mode=rss", hotPhotoListBox); LoadImageListBox("http://f.hatena.ne.jp/iseebi/rss?mode=rss", userPhotoListBox);
ここまででプログラムはこんな感じになっています。
public partial class MainPage : PhoneApplicationPage { const string rssSpace = "{http://purl.org/rss/1.0/}"; const string contentSpace = "{http://purl.org/rss/1.0/modules/content/}"; const string dcSpace = "{http://purl.org/dc/elements/1.1/}"; const string hatenaSpace = "{http://www.hatena.ne.jp/info/xmlns#}"; // Constructor public MainPage() { InitializeComponent(); // Set the data context of the listbox control to the sample data DataContext = App.ViewModel; this.Loaded += new RoutedEventHandler(MainPage_Loaded); // 画像の読み込み LoadImageListBox("http://f.hatena.ne.jp/userlist?mode=rss", newPhotoListBox); LoadImageListBox("http://f.hatena.ne.jp/hotfoto?mode=rss", hotPhotoListBox); LoadImageListBox("http://f.hatena.ne.jp/iseebi/rss?mode=rss", userPhotoListBox); } // Load data for the ViewModel Items private void MainPage_Loaded(object sender, RoutedEventArgs e) { if (!App.ViewModel.IsDataLoaded) { App.ViewModel.LoadData(); } } public void LoadImageListBox(string url, ListBox targetList) { XDocument doc = XDocument.Load(url); var q = from e in doc.Root.Elements() where e.Name.ToString().EndsWith("item") select new HatenaFotolifeRssItem() { Title = e.Element(rssSpace + "title").Value, Link = e.Element(rssSpace + "link").Value, Description = e.Element(rssSpace + "description").Value, Content = e.Element(contentSpace + "encoded").Value, Date = e.Element(dcSpace + "date").Value, ImageUrl = e.Element(hatenaSpace + "imageurl").Value, ImageUrlSmall = e.Element(hatenaSpace + "imageurlsmall").Value, ImageUrlMedium = e.Element(hatenaSpace + "imageurlmedium").Value, Syntax = e.Element(hatenaSpace + "syntax").Value, //Colors = from c in e.Element(hatenaSpace + "colors").Elements() where c.Name == hatenaSpace + "color" select c.Value Colors = (from c in e.Element(hatenaSpace + "colors").Elements() where c.Name == hatenaSpace + "color" select c.Value).ToArray<string>() }; targetList.ItemSource = q; } }
ここまでで実行するとエラーになってしまいます。Windows Phone 7では同期処理は御法度。そういうわけでXDocument.LoadのURL指定は相対パスしか認識してくれないのです。
Reactive Extensions (Rx) で取得するプログラムを書き直す
というわけで、非同期処理でテキストをダウンロードするため、WebClient を使ってダウンロードすることになるのですが、非同期処理と言えば id:shiba-yan が紹介してくれた Reactive Extensions (Rx)!
Windows Phone 7 と Reactive Extensions の不文律
早速このプログラムをRxで書き直してみたいと思います。
まず、必要なアセンブリ参照を先ほどSystem.Xml.Linqを追加したときと同じ要領で追加します。追加するのは以下の2つです。
- Microsoft.Phone.Reactive
- System.Observable
次に、MainPage.xaml.cs 先頭にある using の並んでいるところの末尾行に1行追加します。
using Microsoft.Phone.Reactive;
そしてLoadImageListBoxを修正していきます。まず、WebClientを用意します。
WebClient c = new WebClient();
次に、Rxの処理を書いていきます。Rxはとっつきにくいですが、順を追って書いていけば簡単です。ではいきましょう。
Rxの処理はすべてObservableクラスからはじまり、どこから発火するかをFrom〜で指定します。今回はダウンロード成功のDownloadStringCompletedです。
Observable.FromEvent<DownloadStringCompletedEventArgs>(c, "DownloadStringCompleted")
Selectでイベント引数から必要なデータを取得します。
.Select(p => XDocument.Load(new StringReader(p.EventArgs.Result)))
Rxは基本的に違うスレッドで動いているので、UIのスレッドに移動するように指定して、
.ObserveOnDispatcher()
戻り値をDoで処理させます。先ほどのデータを解析するLINQとリストのItemSourceに入れる処理にします。
.Do(p => { var q = from elem in p.Root.Elements() where elem.Name.ToString().EndsWith("item") select new HatenaFotolifeRssItem() { Title = elem.Element(rssSpace + "title").Value, Link = elem.Element(rssSpace + "link").Value, Description = elem.Element(rssSpace + "description").Value, Content = elem.Element(contentSpace + "encoded").Value, Date = elem.Element(dcSpace + "date").Value, ImageUrl = elem.Element(hatenaSpace + "imageurl").Value, ImageUrlSmall = elem.Element(hatenaSpace + "imageurlsmall").Value, ImageUrlMedium = elem.Element(hatenaSpace + "imageurlmedium").Value, Syntax = elem.Element(hatenaSpace + "syntax").Value, Colors = (from color in elem.Element(hatenaSpace + "colors").Elements() where color.Name == hatenaSpace + "color" select color.Value).ToArray<string>() }; targetList.ItemsSource = q; })
最後はSubscribeで確定!
.Subscribe();
あと、ダウンロード開始するためにWebClientのダウンロード開始すればできあがりです。
c.DownloadStringAsync(new Uri(url));
メソッド全体ではこのようになります。*1
public void LoadImageListBox(string url, ListBox targetList) { WebClient c = new WebClient(); Observable.FromEvent<DownloadStringCompletedEventArgs>(c, "DownloadStringCompleted") .Select(p => XDocument.Load(new StringReader(p.EventArgs.Result))) .ObserveOnDispatcher() .Do(p => { var q = from elem in p.Root.Elements() where elem.Name.ToString().EndsWith("item") select new HatenaFotolifeRssItem() { Title = elem.Element(rssSpace + "title").Value, Link = elem.Element(rssSpace + "link").Value, Description = elem.Element(rssSpace + "description").Value, Content = elem.Element(contentSpace + "encoded").Value, Date = elem.Element(dcSpace + "date").Value, ImageUrl = elem.Element(hatenaSpace + "imageurl").Value, ImageUrlSmall = elem.Element(hatenaSpace + "imageurlsmall").Value, ImageUrlMedium = elem.Element(hatenaSpace + "imageurlmedium").Value, Syntax = elem.Element(hatenaSpace + "syntax").Value, Colors = (from color in elem.Element(hatenaSpace + "colors").Elements() where color.Name == hatenaSpace + "color" select color.Value).ToArray<string>() }; targetList.ItemsSource = q; }) .Subscribe(); c.DownloadStringAsync(new Uri(url)); }
これで再度実行します。今度はうまくいったよ、やったー!
詳細画面を作ってページ遷移させる
さて、リストで項目が選ばれたときに、大きな写真を表示できるようにしたいと思います。
プロジェクトを右クリック→Add→Add Item... です。今度はWindows Phone Portrait Page を選びます。ImagePage.xaml としてください。
画面のデザインから入りますが、ちょっとしたことなのでBlendを使うまでもありません。ImagePage.xamlの編集画面が開いていると思いますので、Visual StudioのToolboxからImageをドラッグします。
グリッドいっぱいに広げます。
image1になっていると思いますので、プロパティウィンドウでimageに名前を変えます。
デザインができたので、ImagePage.xaml.cs を開き、以下のプログラムを書きます。
protected override void OnNavigatedTo(System.Windows.Navigation.NavigationEventArgs e) { if (NavigationContext.QueryString.ContainsKey("image")) { image.Source = new BitmapImage(new Uri(NavigationContext.QueryString["image"])); } }
OnNavigatedTo は別の画面から遷移してくる際に呼ばれるメソッドで、引数が渡されている場合はNavigationContext.QueryStringから取得できるようになっています。今回は、imageパラメータで表示させる画像のURLを指定することにしました。
次に、呼び出し側です。MainPage.xaml.cs を開き、コンストラクタに以下のように入力します。
newPhotoListBox.SelectionChanged +=
すると、以下のようになります。
この状態でタブを2回押し込むと、VisualStudioがイベントハンドラを自動的に作成してくれます。便利!こんな感じになります
// Constructor public MainPage() { InitializeComponent(); // Set the data context of the listbox control to the sample data DataContext = App.ViewModel; this.Loaded += new RoutedEventHandler(MainPage_Loaded); // 画像の読み込み LoadImageListBox("http://f.hatena.ne.jp/userlist?mode=rss", newPhotoListBox); LoadImageListBox("http://f.hatena.ne.jp/hotfoto?mode=rss", hotPhotoListBox); LoadImageListBox("http://f.hatena.ne.jp/iseebi/rss?mode=rss", userPhotoListBox); newPhotoListBox.SelectionChanged += new SelectionChangedEventHandler(newPhotoListBox_SelectionChanged); } void newPhotoListBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { throw new NotImplementedException(); }
ただ、今回は3つともこのイベントハンドラを使うようにするのに名前が不適当です。作成されたイベントハンドラの名前をphotoListBox_SelectionChangedへ変えましょう。すると、Visual Studioはこのようなスマートタグを出してきます。
これを押すと元々のところも書き換えてくれます。かしこい!あとは、他の2つのリストにも同じハンドラを指定し、以下のようにプログラムを記述します。
// Constructor public MainPage() { InitializeComponent(); // Set the data context of the listbox control to the sample data DataContext = App.ViewModel; this.Loaded += new RoutedEventHandler(MainPage_Loaded); // 画像の読み込み LoadImageListBox("http://f.hatena.ne.jp/userlist?mode=rss", newPhotoListBox); LoadImageListBox("http://f.hatena.ne.jp/hotfoto?mode=rss", hotPhotoListBox); LoadImageListBox("http://f.hatena.ne.jp/iseebi/rss?mode=rss", userPhotoListBox); newPhotoListBox.SelectionChanged += new SelectionChangedEventHandler(photoListBox_SelectionChanged); hotPhotoListBox.SelectionChanged += new SelectionChangedEventHandler(photoListBox_SelectionChanged); userPhotoListBox.SelectionChanged += new SelectionChangedEventHandler(photoListBox_SelectionChanged); } void photoListBox_SelectionChanged(object sender, SelectionChangedEventArgs e) { if (e.AddedItems.Count > 0) { HatenaFotolifeRssItem item = (HatenaFotolifeRssItem)e.AddedItems[0]; NavigationService.Navigate(new Uri(string.Format("/ImagePage.xaml?image={0}", item.ImageUrl), UriKind.Relative)); } }
同じプロジェクトにある他のPageは、NavigationService.Navigateに、/からはじめてファイル名を記述して、UrlKind.Relative(相対パス)を指定して呼び出すことができます。このときに、URLのGETパラメータのようにパラメータを指定することができ、受け取り先ではNavigationContextで取り出すことができるようになっています。
では、実行して、リストを選択すると、画像が1枚ページで表示されます。ちょっと比率おかしいけどここは追々調整する形で。
To be continued...
以上、プログラム実装編でした!
今回のプロジェクトファイルはこちらからダウンロードできます。
あとはこのプログラムをBlendでいい感じにするデザイン編を id:c-mitsuba が書いてくれました。
こちらをぜひ参照して、完成させてくださいね!
*1:この記事を書いた後、id:shiba-yan からツッコミがありました。Doの引数に書いている内容はそのままSubscribeの引数に書くことで同じ効果になり、メソッドの呼び出しをDoの分1つ減らせます。