これは
XAML Advent Calendar 2014の14日目の記事です。昨日は
@saka_ponさんでした。皆さんネタが濃いです。この記事は残念ながら濃くはないです。長いですけど(すいません)。
1. 前置き
さて、XAMLのコントロールも色々ありますが、個人的にはItemsControlというか、ListViewが好きです。データソースを繋げたらバインディングが張られた子コントロールがだだっと自動生成されるのがたまらない、というか。アプリを作るときは初めてこれを見るのが楽しみだったりします。
このItemsControlの子コントロールは縦横に並べて配置するのが基本ですが、データソースに含まれる情報、例えば地理的な位置情報がある場合は、UI的にそれを生かした地理的な配置にすることも考えられます。それで地理的な配置にした場合、多様なサイズの画面で使えるようにするにはズームが必要になりますし、ズームすれば今度はムーブも必要になり、さらに画面の回転も考慮する必要が出てきます。
そういったものをWindowsストアアプリのWinRTで一式作ってみようというのが、この記事の目的です。
流れとしては以下のようになります。
- 地理的な配置にする
- ビヘイビアを用意する
- ズームできるようにする
- ムーブできるようにする
- 回転やサイズ変更や終了後の復元ができるようにする
最終的なものは以下のような感じです。記事とは関係ないですが、日本全国の気温(
OpenWeatherMapによる)と各電力会社の電力使用状況をデータとして利用しています。
ソースコードはGitHubに置きました。
2.1. 地理的な配置にする
子コントロールを自由に配置するには、定番ですがListViewのItemsPanelにCanvasを使います。まず子コントロール用のViewModelとして以下のChildViewModelがあるとします。
このLeft、Top、ZIndexをそれぞれCanvasの添付プロパティにバインドしてやればいいわけです(ZIndexはもし必要があれば)。
このChildViewModelを要素とするCoreという名のObservableCollectionがあるとして、これをItemsSourceにしたListViewのXAMLがどうなるかというと、WPFの場合は以下のようになります。
一応これのポイントはItemContainerStyleのプロパティとしてCanvasの添付プロパティを設定し、これらにChildViewModelからバインドしていることです。ItemContainerStyleが適用されるListViewItemがItemTemplateが適用されるコントロールの親になるので、こちらに設定しないとCanvasに届かないわけですね。
で、この方法はWinRTでは通用しません。なぜならItemContainerStyleにバインディングが通らないから。
ではどうするかというと、いきなりコードビハインドですがItemsControl.PrepareContainerForItemOverrideメソッドを使ってバインディングを張ってやります。これを簡単にやるにはListViewの派生クラスを作ればいいので、CanvasListViewというクラスを作ります。
これを使ったWinRTのListViewのXAMLは以下のようになります。
まあ不要なItemContainerStyleのプロパティを削っただけですが。ItemContainerStyle自体は残しているのは標準のStyleを無効にするためです。
2.2. ビヘイビアを用意する
ズーム/ムーブできるようにするには、これも定番ですがListViewをScrollViewerで囲みます。その準備として、ScrollViewerに関係する処理をBehaviors SDK (XAML)のビヘイビア(要はBlendのビヘイビア)にまとめることにします。これは参照設定 -> 参照の追加 -> Windows 8.1 -> 拡張から追加できます。
作成したビヘイビアの基本部分は以下のようなものです。
このビヘイビアには標準でジェネリック版がないので、Attachしたときに対象のDependencyObjectがScrollViewerか確認した上でAssociatedObjectプロパティに格納し、とりあえずScrollViewerにキャストしたものをAssociatedViewerプロパティから参照できるようにしました。
その下のAssociatedSelectorプロパティの型はSelectorですが、これはListViewやGridViewの基底クラスに当たるものです。なるべく汎用性を持たせようとしたのですが、この記事では関係ないのでListViewのことだと思ってください。
この中のGetFirstDescendantOfType<T>メソッドは
WinRT XAML ToolkitのVisualTreeHelperExtensions.GetFirstDescendantOfType<T>拡張メソッドです。これは子孫の中から指定された型の最初のものを取得するので、このプロパティからScrollViewer内にあるListViewを参照できます。
その下のCompositeDisposableは、イベント処理をReactive Extensionsでやるので、その後始末のためです。
また、この記事では直接触れませんが、方針として子コントロールをCanvas内に配置する座標を計算する際、ScrollViewerのサイズを基準にします。これに合わせるため起動時にScrollViewerとListViewのCanvasのサイズを揃えることとし(1倍の状態)、ScrollViewerからの座標の入力があればCanvas内の座標に変換し、さらに1倍のときの座標に変換したものを基準にします。でないと座標を間違えずに取り扱える気がしないので。
それでは、初めに処理の土台となるヘルパーメソッドを作ります。
計算を簡単にするため起動時にScrollViewerとListViewのCanvasのサイズを揃える前提で、ズームしたときの変化とScrollViewerのプロパティの関係を模式化したのが以下です。
ズームインすることによってCanvasの仮想的なサイズが拡大し、Canvasの位置(左上角)とScrollViewerの位置(左上角)がずれます。これからScrollViewer内の任意の座標をCanvas内の座標に変換するには、CanvasのScrollViewerに対する相対座標の値を加えればいいことが分かります。
さらにCanvas内の座標を1倍のときの座標に変換するにはScrollViewerの倍率で割ればいいので、ScrollViewer内の座標をCanvasの1倍のときの座標に変換するメソッドは以下のようになります。引数のinViewerPositionがScrollViewer内の座標、viewerZoomFactorがScrollViewerの倍率です。
selectorPositionはCanvasのScrollViewerに対する相対座標で(負の値になる)、それとScrollViewer内の座標を合計した上で倍率で割っています。
逆に、ScrollViewer内の座標とCanvas内の1倍のときの座標がScrollViewerのある倍率のときに一致するようScrollViewerを操作するメソッドは以下のようになります。引数のinSelectorPositionがCanvas内の座標です。
ScrollViewer.ChangeViewメソッドは水平と垂直の両スクロール量(オフセット)、倍率を一元的に操作するメソッドです。Windows 8.1で入ったメソッドで、従来のバラバラだったメソッドを置き換えるものです。
座標の計算はConvertToInSelectorPositionメソッドの逆になっているのが分かると思います。という意味で対になるメソッドです。
[追記]
ConvertToInSelectorPositionメソッドについて、CanvasとScrollViewerの相対座標の値はScrollViewerのHorizontalOffsetとVerticalOffsetに一致するから、わざわざメソッドを使って取得する必要はないのではと思った方もいるかもしれません。メソッドにするとこんな感じです。
そうしなかった理由は自分でも忘れてましたが、この方法は倍率が1より小さくなる、すなわちCanvasがScrollViewerより小さくなったときは無力になるからです。HorizontalOffsetとVerticalOffsetは0より小さくならないので。
と言いつつ、倍率の最小値を1に制限してしまえば問題にはならないので、これでも行けるんですけどね。
2.3. ズームできるようにする
ようやくズームですが、WinRTのScrollViewerの場合、ZoomModeをEnabledにするだけでタッチのピンチ/ムーブが可能になります。したがって、以下はマウスでこれをやるためのものです(タッチも受け付けますが)。
なお、ズームといっても中心点を固定して拡大/縮小する形式と、ズームイン/ズームアウトのモードを決めて任意の点をクリックするとその点を中心に拡大/縮小する形式がありますが、UI的に後者の方が優れていると思うので、そちらで行きます。
ということで、ズームイン/ズームアウトのモードを示すenumを用意しました。
このモード設定は他に任せ、このビヘイビアにはこの型のZoomDirection依存関係プロパティを置き、PageのViewModelとバインドして現在の設定を受け取れるようにします。
その上でScrollViewerのTappedイベントをRxで購読するメソッドを作り、ビヘイビアのOnLoadedメソッドで実行します。
Tappedイベントが来たときに処理するメソッドは以下のとおりです。
まずScrollViewer内の座標とCanvas内の1倍のときの座標を取得し、ZoomDirectionに従って振り分けます。zoomNotchFactorは1回の操作で変える倍率の刻みです。倍率は最小1倍、最大9倍でリミットをかけています。
面倒な計算はヘルパーメソッドに任せているので、こんなものです。
2.4. ムーブできるようにする
ムーブもイベントをRxで購読すればいいわけですが、これには二通りあります。
- Manipulationイベント: ManinulationStartedで開始、ManipulationDeltaで移動、ManipulationCompletedで終了。
- Pointerイベント: PointerPressedで開始、PointerMovedで移動、PointerReleasedなどで終了。
どちらでも行けますが、まずManipulationの方から。イベントを購読するメソッドは以下のようなものです。
移動量を捉えるだけならManipulationDeltaだけでいいですが、開始位置を保存しておくのと入力がマウスかどうか判別するにはManipulationStartedRoutedEventArgsを見る必要があるので、ManipulationStartedが必要になります。一方、移動が終わればManipulationDeltaも止まるので、ManipulationCompletedには実質的な意味はありません。
これらを受けた処理をするメソッドです。
開始時にその時点の状態を保存しておき、移動時に(スロットリングで間隔を挟みつつ)開始時からの累積量を加え、ScrollViewer.ChangeViewメソッドで反映させています。一応倍率(Scale)の変化も加えるようにしていますが、無駄かもしれません。
次にPointerの方を。こちらは少し注意が必要です。
第一に、PointerMovedイベントはカーソルが上を動いているときは常に発生しているので、区切るために開始時と終了時のイベントが必須ですが、終了時のイベントがItemsControlを囲んだScrollViewerでは返ってこないので(自分で試した結果)、ListViewの方でイベントを購読する必要があります。
第二に、終了時のイベントが何になるのか明示されていません。
MSDNには「PointerReleasedの代わりの他のイベントが、アクション — たとえば、PointerCanceledまたはPointerCaptureLostの最後に発生する場合があります。常にペアで発生するPointerPressedおよびPointerReleasedイベントに依存しないでください。」とあり、PointerReleasedだけではダメなのは分かりますが、PointerCanceledとPointerCaptureLostも例示に過ぎないので、完全に止められる保証がありません。
ともあれ、イベントを購読するメソッドは以下のようなものです。
終了時はPointerReleasedとPointerCanceledとPointerCaptureLostをまとめて購読する形です。保険のためにPointerExitedを追加してもいいかもしれません。
これらを受けたメソッドです。
Manipulationの場合とやっていることは大体同じです。これで別に問題は起きなかったのですが、どちらかを選ぶとすればManipulationの方が面倒がないかなという気がします。
2.5. 回転やサイズ変更や終了後の復元ができるようにする
もう少し続きます。
回転への対応はストアアプリならではの要求ですが、このScrollViewerの場合は、今まで見ていたものが回転しても明後日の方に飛んでいかない、言い換えれば中心にあるものは回転しても中心のままにする、と捉えることができます。
これはサイズ変更への対応にも流用できて、アプリの横幅が変更されても中心にあるものは中心に維持される、となります。さらに終了後の復元への対応にも応用できて、終了時に中心にあったもの(と倍率)がそのまま復元される、となります。
したがって、これらは共通の処理で実現でき、違うのはタイミングだけです。このためにはScrollViewerの中心座標に対応するCanvasの1倍のときの座標とScrollViewerの倍率を記録しておいて、それぞれ適当なタイミングで復元すればいいわけです。
まずこの座標を記録するものとしてPoint型のInSelectorCenterPosition依存関係プロパティ、倍率を記録するものとしてFloat型のViewerZoomFactor依存関係プロパティを置きます。これらはPageのViewModelとバインドして変更がある度にLocalSettingsかRoamingSettingsに保存するようにすれば、終了後の復元にも使えます。
これらを記録するメソッドは以下のとおりです。
復元するメソッドの方は以下のとおりです。
この記録するタイミングにはScrollViewerのViewChangedイベントが利用できます。これはタッチによる変更も捉えることができます。また、回転とサイズ変更のときに復元するタイミングにはSizeChangedイベントが利用できます(回転で縦横が入れ替わる、すなわちサイズが変わるので)。
これらをRxで購読するにはOnLoadedイベントで以下のようにします。
終了後の復元のときは、起動中の適当なタイミングで復元を実行すればいいわけですが、注意点としてListViewの子コントロールがロードされた後でなければ正しく復元されないので工夫が必要です。
最終的なビヘイビアは
レポジトリの方で。記事中で取り上げてない部分もありますが。
ScrollViewerのXAMLは以下のようになりました。
表示させるとこんな感じです。
デザインは作り込んでいませんが、フライアウトを付けたりしています。
以上で終わりです。お疲れ様でした。
3. 後書き
実はこの方法は
TokyoSubwayViewで使ったものとほぼ同じだったりします。記事の材料にするために見直したりしてきましたが、まだ生煮えの感を免れません。強引にまとめようとすれば出来なくもないかもしれませんが、それがXAML的に正しいかという判断が付かない程度のXAML力でした。
ということで、もうXAML Advent Calendarも後半ですが、引き続き各位の記事で勉強させていただこうと思います。明日は
@icchuさんです。期待してます。