複数のバインディング・ソースを画面にバインドするには?[Win 8]:WinRT/Metro TIPS
複数のバインディング・ソースを画面にバインドする方法として簡易的な手法を解説。今回はLayoutAwarePageクラスに用意されているDefaultViewModelプロパティを使う。
powered by Insider.NET
画面にデータをバインドするときに、複数のバインディング・ソースがあるときはどうしたらよいだろうか? 1つの回答は「WinRT/Metro TIPS:複雑なデータをバインドするには?[Win 8/WP 8]」で紹介したように、それらを1つのクラスにまとめることだ。しかし、Windowsストア・アプリ用のテンプレートには、それとは異なる便利な手段も用意されている。本稿では、その手段を利用する方法を解説する。本稿のサンプルは「Windows Store app samples:MetroTips #39(Windows 8版)」からダウンロードできる。
●事前準備
Windows 8(以降、Win 8)向けのWindowsストア・アプリを開発するには、Win 8とVisual Studio 2012(以降、VS 2012)が必要である。これらを準備するには、第1回のTIPSを参考にしてほしい。本稿では64bit版Win 8 ProとVS 2012 Express for Windows 8を使用している。
●複数のバインディング・ソースを1つのクラスにまとめる方法(復習)
「WinRT/Metro TIPS:複雑なデータをバインドするには?[Win 8/WP 8]」で紹介した方法は、次の図のように、複数のバインディング・ソース(=複合データ)を1つのクラス(=SampleCompositeDataクラス)にまとめておいて、それをページのデータ・コンテキストに割り当てるというものだった。
複合データをページのデータ・コンテキストに割り当て、各コントロールはデータ・コンテキストとバインドする(Win 8)
「WinRT/Metro TIPS:複雑なデータをバインドするには?[Win 8/WP 8]」から再掲。
●汎用的な「データの入れ物」にまとめてみる
上記のようにした理由は、煎じ詰めればページのデータ・コンテキストに1つのオブジェクトしか設定できないからである。しかし、それならば汎用的な「データの入れ物」を用意しておいて、そこに複数のバインディング・ソースを入れてもよさそうだ。まずは、それを試してみよう。
複数のバインディング・ソースとしては、「WinRT/Metro TIPS:複雑なデータをバインドするには?[Win 8/WP 8]」で紹介したClockクラスとSampleDataクラスを再び使う。
汎用的な「データの入れ物」としては、Dictionary<string, object>クラス(System.Collections.Generic名前空間)を使ってみよう*1。まず、コードビハインドのメンバ変数として「データの入れ物」を用意する(次のコード)。
*1 ほかのコレクションでも可能ではある。ただし、例えばList<object>クラス(System.Collections.Generic名前空間)を使うと、バインドを設定するときにリストのインデックスを使うことになる。Dictionary<string, object>クラスであれば、名前でバインドできる。
// 「データの入れ物」としてDictionary<string, object>オブジェクトを用意
private Dictionary<string, object> _dataContext = new Dictionary<string, object>();
' 「データの入れ物」としてDictionary<string, object>オブジェクトを用意
Private _dataContext As New Dictionary(Of String, Object)()
「データの入れ物」としてDictionary<string, object>クラスのインスタンスを用意し、名前を「_dataContext」とした。
画面には、次のコードのように、Page.Resources要素と表示のためのコントロールを追加する。
…… 省略 ……
<Page.Resources>
<x:String x:Key="AppName">WinRT/Metro TIPS #39 - Dictionaryを使ってみる</x:String>
<DataTemplate x:Key="DataTemplate1">
<Grid Margin="6" Background="DarkCyan" >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="2*"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Background="BlueViolet" Margin="10,5,5,5" >
<TextBlock Text="{Binding Title}" FontSize="30" TextWrapping="Wrap" />
</Border>
<Border Grid.Column="1" Background="DarkBlue" Margin="0,5,10,5">
<TextBlock Text="{Binding Url}" FontSize="18" TextTrimming="WordEllipsis" VerticalAlignment="Center" />
</Border>
</Grid>
</DataTemplate>
</Page.Resources>
…… 省略 ……
<Grid Style="{StaticResource LayoutRootStyle}">
…… 省略 ……
<StackPanel Grid.Row="1" Margin="120,40,80,80">
<StackPanel x:Name="panelDateTime"
Orientation="Horizontal">
<TextBlock Text="現在日時" FontSize="42" />
<TextBlock Text="{Binding SampleClock.NowTime}"
FontSize="42" Foreground="Orange" Margin="10,0,0,0" />
</StackPanel>
<StackPanel Margin="0,40,0,0">
<TextBlock Text="SampleDataCollection" FontSize="42" />
<ListView ItemsSource="{Binding SampleCollection}"
ItemTemplate="{StaticResource DataTemplate1}"
Height="300" Width="1000" HorizontalAlignment="Left"
ScrollViewer.VerticalScrollMode="Enabled"
>
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="Width" Value="980" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
</ListView.ItemContainerStyle>
</ListView>
</StackPanel>
</StackPanel>
…… 省略 ……
追加/変更部分のみ抜粋した(詳細は公開するソース・コードを参照してほしい)。
そうしたら、次のコードのようにして、ページが表示されるときに、複数のバインディング・ソースを「データの入れ物」に格納してからページのデータ・コンテキストにセットする。
protected override void LoadState(Object navigationParameter, Dictionary<String, Object> pageState)
{
// 「データの入れ物」にClockを追加
_dataContext.Add("SampleClock", new Clock());
//// 「データの入れ物」をページのDataContextにセット
// this.DataContext = _dataContext; // (1) ……ここで実行するとsampleCollectionは表示されない
// listView1に表示するデータを作り、「データの入れ物」に追加
var sampleCollection = new ObservableCollection<SampleData>();
_dataContext.Add("SampleCollection", sampleCollection);
sampleCollection.Add(new SampleData()
{
Title = "Metroスタイル・アプリの開発者が知るべき3つのこと",
Url = "http://www.atmarkit.co.jp/fdotnet/chushin/readyforwin8app_01/readyforwin8app_01_01.html",
});
sampleCollection.Add(new SampleData()
{
Title = "デザイン・ガイドラインに従って画面を作成するには?[Win 8]",
Url = "http://www.atmarkit.co.jp/ait/articles/1208/23/news131.html",
});
sampleCollection.Add(new SampleData()
{
Title = "メニューの代わりにアプリ・バーを使うには?[Win 8]",
Url = "http://www.atmarkit.co.jp/ait/articles/1208/30/news149.html",
});
// 「データの入れ物」をページのDataContextにセット
this.DataContext = _dataContext; // (2)
}
Protected Overrides Sub LoadState(navigationParameter As Object, pageState As Dictionary(Of String, Object))
' 「データの入れ物」にClockを追加
_dataContext.Add("SampleClock", New Clock())
'' 「データの入れ物」をページのDataContextにセット
' Me.DataContext = _dataContext ' (1) ……ここで実行するとsampleCollectionは表示されない
' listView1に表示するデータを作り、「データの入れ物」に追加
Dim sampleCollection = New ObservableCollection(Of SampleData)()
_dataContext.Add("SampleCollection", sampleCollection)
sampleCollection.Add(New SampleData() With
{
.Title = "Metroスタイル・アプリの開発者が知るべき3つのこと",
.Url = "http://www.atmarkit.co.jp/fdotnet/chushin/readyforwin8app_01/readyforwin8app_01_01.html"
})
sampleCollection.Add(New SampleData() With
{
.Title = "デザイン・ガイドラインに従って画面を作成するには?[Win 8]",
.Url = "http://www.atmarkit.co.jp/ait/articles/1208/23/news131.html"
})
sampleCollection.Add(New SampleData() With
{
.Title = "メニューの代わりにアプリ・バーを使うには?[Win 8]",
.Url = "http://www.atmarkit.co.jp/ait/articles/1208/30/news149.html"
})
' 「データの入れ物」をページのDataContextにセット
Me.DataContext = _dataContext ' (2)
End Sub
このほか、C#のコードにはSystem.Collections.ObjectModel名前空間のusingが必要である。
コメントにある(1)、(2)については後述する。
なお、このコードはLayoutAwarePageクラスを継承してページを作った場合である。Pageクラスを継承したときは、OnNavigateToメソッドに記述する。
これで実行してみると、次の画像のように表示される。
これでよさそうに思える。実際、ページの表示時にデータを与えるだけなら、これでも不都合はないだろう。しかし、この方法ではページを表示した後でデータを与えられない。取りあえずページを表示しておき、データは後から非同期で取得してくるという手法が使えないのだ。
そのことを確かめておこう。上のコードでは最後の(2)の部分で、「データの入れ物」をページのDataContextにセットしているが、これをコメントにする。そして、(1)の部分のコメントを外して、「データの入れ物」をDataContextプロパティにセットしてからSampleDataクラスのデータを格納するように変更する。これで実行してみると、(1)より前で格納したClockクラス(=現在日時の表示)は表示されるが、(1)より後で格納したSampleDataクラス(=リスト表示)は表示されない。
これは、Dictionary<string, object>クラスには、その変更をバインディング・ターゲットに通知する機能がないからだ。バインディング・ソースにはINotifyPropertyChangedインターフェイス(System.ComponentModel名前空間)*2やINotifyCollectionChangedインターフェイス(前同)*3などを実装して、変更をバインディング・ターゲットに通知するようになっていなければならない。
汎用的な「データの入れ物」とするには、複数のオブジェクトを任意に格納できないといけないから、INotifyPropertyChangedインターフェイスでは無理がある。ObservableCollection<T>クラス(System.Collections.ObjectModel名前空間)*4ならばINotifyCollectionChangedインターフェイスを実装しているが、格納しているオブジェクトを名前で参照できないので、バインディングの指定がやりづらい。
*2 INotifyPropertyChangedインターフェイスは「WinRT/Metro TIPS:文字列をコントロールにバインドするには?[Win 8/WP 8]」で紹介している。
*3、*4 INotifyCollectionChangedインターフェイスとObservableCollection<T>クラスは「WinRT/Metro TIPS:複雑なデータをバインドするには?[Win 8/WP 8]」で紹介している。
●IObservableMap<K,V>インターフェイス
以上のような問題点を解消するには、Dictionary<string, object>クラスのように格納しているオブジェクトを名前で指定できて、かつ、バインディング・ターゲットに通知する機能を持ったインターフェイスが欲しくなる。それがIObservableMap<K, V>インターフェイス(Windows.Foundation.Collections名前空間)だ。
残念なことに、IObservableMap<K, V>インターフェイスを実装したクラスは標準では用意されていない。MSDNの解説を見ると、IDictionary<K, V>インターフェイスとMapChangedイベントを実装するだけでよいので、作るのはたいして難しくはなさそうだ。しかし、MapChangedイベントを実装するには、そのコレクションへオブジェクトが追加されたとき/削除されたとき/全部クリアされたときなどにイベントを発火させる必要があり、難しくはなくても面倒な作業になる。
●LayoutAwarePageクラスの秘密
VS 2012が自動生成するLayoutAwarePageクラス*5には、さまざまな機能が含まれている。その1つに、DefaultViewModelプロパティがある。実は、これがIObservableMap<K,V>インターフェイスを実装しているのだ。そして、VS 2012でプロジェクトに新しいページ([空白のページ]を除く、[基本ページ]や[分割ページ]など)を追加すると、LayoutAwarePageクラスを継承したクラスが作られる。
*5 LayoutAwarePageクラスを自動生成するには、VS 2012のプロジェクト・テンプレートで[グリッド アプリケーション (XAML)]か[分割アプリケーション (XAML)]を選ぶ。これら以外のプロジェクト・テンプレートを使用している場合には、「WinRT/Metro TIPS:文字列以外の値をコントロールにバインドするには?[Win 8/WP 8]」の注「*2」やMSDNの「"Hello, world" アプリを作成する」の中の「新しいアプリの MainPage を置き換えるには」で説明されているようにして、プロジェクトに[基本ページ]を追加すると、Commonディレクトリ以下にLayoutAwarePageクラスなどが自動生成される。ただし、[基本ページ]を追加する方法ではVS 2012がハングアップしたりすることがある(筆者も10回に1回くらいは遭遇している)ので、うまくいかないようなら[分割アプリケーション (XAML)]でプロジェクトを作ってから不要なファイルを削除するとよい(その手順は「MSDN Blogs - 高橋 忍のブログ - Windows 8 で解像度に対応すること 【横画面実装編】」で紹介されている)。いったんLayoutAwarePageクラスなどが自動生成されてしまえば、その後で[基本ページ]を追加しても問題は出ないはずだ。
なお、MainPageのコードを書き進めてからLayoutAwarePageクラスを生成した場合には、コードビハインドでMainPageの基底クラスにCommon.LayoutAwarePageクラスを指定し、XAMLコードではトップレベルの<Page>〜</Page>タグを<common:LayoutAwarePage>〜</common:LayoutAwarePage>タグにすることで、LayoutAwarePageクラスをメインページに使えるようになる(実際には、XAMLコードでのcommon名前空間の定義なども必要だ)。
LayoutAwarePageクラスの中には、ObservableDictionary<K, V>クラスが定義されている。詳しくはそのソース・コードを読んでもらうのがよいが、ここで重要なのは、ObservableDictionary<K, V>クラスは前述のIObservableMap<K, V>インターフェイスを実装しており、KeyValuePair<K, V>クラスのオブジェクトを自由に追加/削除できるようになっていることだ。
そして、LayoutAwarePageクラスのDefaultViewModelプロパティの実体は、ObservableDictionary<string, object>クラスのインスタンスとなっており、KeyValuePair<string, object>クラスのオブジェクトを格納できるようになっている。すなわち、DefaultViewModelプロパティはDictionary<string, object>クラスのように使えるとともに、バインディング・ソースにもなれるのだ。
また、LayoutAwarePageクラスのDefaultViewModelプロパティは、同じく自動生成されたページのデータ・コンテキストにあらかじめセットされている(次のコード)。
<common:LayoutAwarePage
x:Name="pageRoot"
x:Class="MetroTips039CS.MainPage"
DataContext="{Binding DefaultViewModel, RelativeSource={RelativeSource Self}}"
…… 省略 ……
ページのデータ・コンテキストに、DefaultViewModelプロパティがあらかじめセットされている。
[新しいアプリケーション]プロジェクト・テンプレートを利用している場合には、上記の脚注(*5)で述べたようにしてCommonディレクトリ以下にLayoutAwarePageクラスなどを自動生成し、コードビハインドおよびXAMLコードの両者でメインページの基底クラスをLayoutAwarePageクラスにした後に、このコードを必要に応じて追記する(x:classなどはすでに定義されているので追記の必要はない)。
すなわち、LayoutAwarePageクラスを使うように自動生成されたページ(=[空白のページ]以外のページ・テンプレート)では、コードビハインドからは複数のバインディング・ソースに自由に名前を付けてDefaultViewModelプロパティに格納できる。また、XAMLコード側からはそれらのデータを、ページのデータ・コンテキストから名前を使って自由にバインドできるのだ(次の図)。
●複数のバインディング・ソースを画面にバインドするには?
それでは、DefaultViewModelプロパティを使うように、先ほど作ったコードを変更してみよう。まず、メンバ変数の_dataContextを削除し、代わりにDefaultViewModelを使う。また、DataContextプロパティへの代入も削除する*6(前掲のリストの(1)、(2)の部分)。すると次のコードのようになる。
protected override void LoadState(Object navigationParameter, Dictionary<String, Object> pageState)
{
// 「データの入れ物」にClockを追加
base.DefaultViewModel.Add("SampleClock", new Clock());
// listView1に表示するデータを作り、「データの入れ物」に追加
var sampleCollection = new ObservableCollection<SampleData>();
base.DefaultViewModel.Add("SampleCollection", sampleCollection);
sampleCollection.Add(new SampleData()
{
Title = "Metroスタイル・アプリの開発者が知るべき3つのこと",
Url = "http://www.atmarkit.co.jp/fdotnet/chushin/readyforwin8app_01/readyforwin8app_01_01.html",
});
sampleCollection.Add(new SampleData()
{
Title = "デザイン・ガイドラインに従って画面を作成するには?[Win 8]",
Url = "http://www.atmarkit.co.jp/ait/articles/1208/23/news131.html",
});
sampleCollection.Add(new SampleData()
{
Title = "メニューの代わりにアプリ・バーを使うには?[Win 8]",
Url = "http://www.atmarkit.co.jp/ait/articles/1208/30/news149.html",
});
}
Protected Overrides Sub LoadState(navigationParameter As Object, pageState As Dictionary(Of String, Object))
' 「データの入れ物」にClockを追加
MyBase.DefaultViewModel.Add("SampleClock", New Clock())
' listView1に表示するデータを作り、「データの入れ物」に追加
Dim sampleCollection = New ObservableCollection(Of SampleData)()
MyBase.DefaultViewModel.Add("SampleCollection", sampleCollection)
sampleCollection.Add(New SampleData() With
{
.Title = "Metroスタイル・アプリの開発者が知るべき3つのこと",
.Url = "http://www.atmarkit.co.jp/fdotnet/chushin/readyforwin8app_01/readyforwin8app_01_01.html"
})
sampleCollection.Add(New SampleData() With
{
.Title = "デザイン・ガイドラインに従って画面を作成するには?[Win 8]",
.Url = "http://www.atmarkit.co.jp/ait/articles/1208/23/news131.html"
})
sampleCollection.Add(New SampleData() With
{
.Title = "メニューの代わりにアプリ・バーを使うには?[Win 8]",
.Url = "http://www.atmarkit.co.jp/ait/articles/1208/30/news149.html"
})
End Sub
DefaultViewModelプロパティは、XAMLコードでページのデータ・コンテキストにあらかじめセットされているが、IObservableMap<K, V>インターフェイスを実装しているので、このように後からデータを格納しても画面に反映される。
*6 前述したようにページのデータ・コンテキストには、XAMLコード側でDefaultViewModelプロパティがあらかじめセットされている。先のコードでは、それをDictionary<string, object>クラスのインスタンスで上書きしていたのだ。
このようにして、複数のバインディング・ソースをまとめて格納するクラスを新しく作らなくても、LayoutAwarePageクラスに用意されているDefaultViewModelプロパティを使えば簡単にデータ・バインドが行える。
ただし、同じことをXAMLコードだけでは行えないため、デザイン画面で表示に反映させるのは難しい。コントロールにデザイン時データを個別にバインドしたりするなどの工夫が必要になる。バインドが複雑なアプリでは、複数のバインディング・ソースをまとめて格納するクラスを新しく作った方がよいだろう。MSDNの「ナビゲーション モデル」のページには「DefaultViewModelは、便宜的に提供されており、必要に応じて、厳密に型指定されたビュー モデルに変更できます」と書かれているが、大規模なアプリではむしろ「変更するのが望ましい」のである。
●まとめ
複数のバインディング・ソースを画面にバインドするときは、LayoutAwarePageクラスに用意されているDefaultViewModelプロパティを使うと簡単にできる。ただし、デザイン画面で表示に反映させるのは難しい。この方法は簡易的なやり方だと理解しておいてほしい。
Copyright© Digital Advantage Corp. All Rights Reserved.