ストアアプリでマスター詳細シナリオ

これは XAML Advent Calender 2013 8日目の記事です。

アプリを作っていると ListBox などで選択されたアイテムの詳細情報を表示したいときがありますよね。

MSDN ではこのようなシナリオをマスター詳細バインドと呼んでいます。

階層データにバインドし、マスター/詳細ビューを作る方法 (C#/VB/C++ と XAML を使った Windows ストア アプリ) (Windows)

本記事ではストアアプリでマスター詳細シナリオを実装しながら、その際に発生するいくつかの問題とその回避方法を紹介したい思います。

 

お品書き

1.表示データの準備

2.CollectionViewを利用したマスター詳細シナリオ

3.Elementのプロパティを参照した代替方法

4.アイテム非選択状態における挙動

5.詳細のテンプレートを切り替える

6.まとめ

 

1.表示データの準備

 適当に用意したPersonクラスのObservableCollectionをバインドしてCollectionViewSourceを作成します。

    <Page.Resources>
        <CollectionViewSource x:Key="PersonsView" Source="{Binding Path=Persons}" />
    </Page.Resources>

今回用意したコードビハインドはこちらになります。

    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }
    }
    public sealed partial class MainPage : Page
    {
        public ObservableCollection<Person> Persons { get; set; }

        public MainPage()
        {
            this.InitializeComponent();

            Persons = new ObservableCollection<Person>(from x in Enumerable.Range(1, 3) select new Person { Name = "Hoge" + x, Age = x });
            this.DataContext = this;
        }

 

2.CollectionViewを利用したマスター詳細シナリオ

一覧と詳細をListBoxとContentControlで表現します。

 <ListBox x:Name="PersonsListBox" ItemsSource="{Binding Source={StaticResource PersonsView}}" ItemTemplate={StaticResource ListBoxItemTemplate} />
 <ContentControl Content="{Binding Source={StaticResource PersonsView}}" IsTabStop="False" ContentTemplate={StaticResource DetailTemplate} />

CollectionViewSourceはCollectionを提供するためのプロキシ的な役割をつとめるので、両方のコントロールに前節で準備したPersonsViewをバインドします。

ContentControlのバインディングソースにCollectionViewSourceをバインド(つまりContentControlにCollectionViewをバインド)した場合、
 コントロール内部のコンテンツには自動的にCollectionView.CurrentItemがバインドされListBoxの選択アイテムが詳細に表示されます。

参照するテンプレートをリソースに定義しておきます。

                        <DataTemplate x:Key="ListBoxItemTemplate">
                            <TextBlock Text="{Binding Path=Name}" />
                        </DataTemplate>

                        <DataTemplate x:Key="DetailTemplate">
                            <StackPanel Background="Green">
                                <StackPanel.Resources>
                                    <Style TargetType="TextBlock">
                                        <Setter Property="FontSize" Value="30" />
                                    </Style>
                                </StackPanel.Resources>
                                <StackPanel Orientation="Horizontal">
                                    <TextBlock Text="{Binding Name}" />
                                    <TextBlock Text="さん" />
                                </StackPanel>
                                <StackPanel Orientation="Horizontal">
                                    <TextBlock Text="{Binding Age}" />
                                    <TextBlock Text="才" />
                                </StackPanel>
                            </StackPanel>
                        </DataTemplate>

ではさっそく実行してみましょう。

f:id:nya360:20131207223827p:plain

 

ListBoxで選択されたたアイテムの詳細が直下のContentControlに表示され、無事目的の動作を実現できました。

しかしながらIDEに戻ると出力ウインドウに以下のエラー表示を確認できます。

Error: BindingExpression path error: 'Name' property not found on 'Windows.UI.Xaml.Data.ICollectionView'. BindingExpression: Path='Name' DataItem='Windows.UI.Xaml.Data.ICollectionView'; target element is 'Windows.UI.Xaml.Controls.TextBlock' (Name='null'); target property is 'Text' (type 'String')
Error: BindingExpression path error: 'Age' property not found on 'Windows.UI.Xaml.Data.ICollectionView'. BindingExpression: Path='Age' DataItem='Windows.UI.Xaml.Data.ICollectionView'; target element is 'Windows.UI.Xaml.Controls.TextBlock' (Name='null'); target property is 'Text' (type 'String')

 

これはXAMLをParseする時点ではまだCollectionView.CurrentItemが確定しておらず、ContentControl内部のバインド対象が解決できないために発生します。

ListBoxにCollectionViewがバインドされることで初めて選択アイテムが確定しバインディングの解決ができるようになります。 *1

 

3.Elementのプロパティを参照した代替方法

次はContentControlをListBoxのSelectedItemにバインドしてしまう方法で同じ機能を実装してみます。

先のコードからの変更箇所はContentControlのバインディング表記だけです。

 <ContentControl Content="{Binding ElementName=PersonsListBox, Path=SelectedItem}" ItemTemplate={StaticResource ListBoxItemTemplate} />

f:id:nya360:20131207223827p:plain

先ほどと同じ機能が得られました。

また、同様のエラーが表示されています。

Error: BindingExpression path error: 'Age' property not found on 'HatenaApp2.MainPage'. BindingExpression: Path='Age' DataItem='HatenaApp2.MainPage'; target element is 'Windows.UI.Xaml.Controls.TextBlock' (Name='null'); target property is 'Text' (type 'String')

 ただし、今回はAgeだけで、NameのBindingExpression path errorが出ていません。

これはXAMLのParse時にContentControlにバインドするデータが特定できずDataContextがバインドされ、そのDataContextにたまたまNameプロパティが存在していたためです。 *2

 

4.アイテム非選択状態における挙動

続いて、それぞれの手法における挙動を調べていきます。

追加・削除機能をつけてアイテム非選択状態の確認を行います。

f:id:nya360:20131207223837p:plain

*3

アイテムをすべて削除してListBoxが非選択になるとSelectedItemやView.CurrentItemはNullになります。

CollectionViewSourceを利用したシナリオでは、ContentControlにバインドされているのはCollectionViewなのでコントロール内部のコンテンツのDataContextがNullになり
 以下のようなContentControlが表示されます。

f:id:nya360:20131207223842p:plain

一方でElementのプロパティを参照した方法ではContentControlに直接Nullがバインドされ詳細コントロール自体が消えてしまいます。

f:id:nya360:20131207223849p:plain

*4

それでは、初めからListBoxが空だった場合はどうなるでしょうか?

//Persons = new ObservableCollection<Person>(from x in Enumerable.Range(1, 3) select new Person { Name = "Hoge" + x, Age = x });
 Persons = new ObservableCollection<Person>();

CollectionViewSourceを利用したシナリオでは先ほどと変わりません。

f:id:nya360:20131207223842p:plain

しかし、Elementのプロパティを参照した方法では詳細コントロールが表示されました。

f:id:nya360:20131207223842p:plain

前項で説明した通り、XAMLのParse時にContentControlにバインドするデータが特定できずDataContextがバインドされたためです。

ページ初期表示とその後でListBoxが空のときの詳細表示が変わってしまうのはとても困ります。

そこでAdvent Calender5日目tanaka733さんの記事バインディングプロパティを使いましょう。

未選択時用のリソースを作成しておき

   <local:Person x:Name="NullPerson" Name="NullPerson" Age="0" />

ContentControlのバインディングにFallbackValue={StaticResource NullPerson}, TargetNullValue={StaticResource NullPerson}を追加します。

すると、初回表示時とその後の表示を統一することができました。

f:id:nya360:20131207223853p:plain

さらに今まで出ていたBindingExpression path errorも抑制することができます。

 

5.詳細のテンプレートを切り替える

ContentControl.ContentTemplateSelectorを使ってアイテムの内容によって詳細の表示方法を切り替えます。

CollectionViewSourceを利用した場合は、常に同一のオブジェクト(CollectionView)がバインドされるためテンプレートが切り替わらないので、Elementのプロパティを参照する方法で実装します。

Ageが偶数のときに最初の、奇数の時に2つ目のテンプレートを設定するDataTemplateSelectorを定義します。

    public class DetailTemplateSelector : DataTemplateSelector
    {
        List<DataTemplate> _templates = new List<DataTemplate>();
        public List<DataTemplate> Templates
        {
            get { return _templates; }
            set { _templates = value; }
        }
        protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
        {
            var who = item as Person;
            if (who != null)
            {
                return Templates[who.Age % 2];
            }
            return null;
        }
    }

 

リソースにTemplateSelectorの作成。2つのテンプレートも作成

        <local:DetailTemplateSelector x:Key="DetailTemplateSelector">
            <local:DetailTemplateSelector.Templates>
                <DataTemplate>
                    <StackPanel Background="Green">
                        <StackPanel.Resources>
                            <Style TargetType="TextBlock">
                                <Setter Property="FontSize" Value="30" />
                            </Style>
                        </StackPanel.Resources>
                        <StackPanel Orientation="Horizontal">
                            <TextBlock Text="{Binding Name}" />
                            <TextBlock Text="さん" />
                        </StackPanel>
                        <StackPanel Orientation="Horizontal">
                            <TextBlock Text="{Binding Age}" />
                            <TextBlock Text="才" />
                        </StackPanel>
                    </StackPanel>
                </DataTemplate>
                <DataTemplate>
                    <StackPanel Background="Red">
                        <StackPanel.Resources>
                            <Style TargetType="TextBlock">
                                <Setter Property="FontSize" Value="30" />
                            </Style>
                        </StackPanel.Resources>
                        <StackPanel Orientation="Horizontal">
                            <TextBlock Text="{Binding Name}" />
                            <TextBlock Text="さん" />
                        </StackPanel>
                        <StackPanel Orientation="Horizontal">
                            <TextBlock Text="{Binding Age}" />
                            <TextBlock Text="才" />
                        </StackPanel>
                    </StackPanel>
                </DataTemplate>
            </local:DetailTemplateSelector.Templates>
        </local:DetailTemplateSelector>

 詳細ContentControlでTemplateSelectorを指定

  <ContentControl Content="{Binding ElementName=PersonsListBox, Path=SelectedItem}" ContentTemplateSelector="{StaticResource DetailTemplateSelector}"/>

 f:id:nya360:20131207223859p:plain

NullがバインドされるとContentControl自体が非表示になってしまいますが、 前節のバインディングプロパティと組み合わせれば安定した詳細表示が実現できるのではないでしょうか。

 

まとめ

以上、ストアアプリでマスター詳細シナリオを実現する場合、CollectionViewを用いたView共有はエラーが出力されたりContentTemplateSelectorを利用できないなどの制限があるため、現状ではElementのプロパティを参照する方法でそれらを回避するのが良いかと思います。

最後に直前のtanaka733さんの記事がなければElementのプロパティを参照する方法の問題点を解決できませんでした。素晴らしい記事をありがとうございます。 

*1:Windows8 Clinicによると現時点ではこのエラー出力を抑制する方法はないとのことです。

*2:こちらのエラーは後述する方法で回避ができます。一方、CollectionViewSourceシナリオで回避できないのはCollectionViewのプロキシ処理内部でのエラーとなるためではないかと推測します。

*3:記事の対象とするところではないためソースは割愛します

*4:削除時にListBoxが非選択状態になるのでBindingExpression path errorが出力されますがそれも後述の方法で抑制できます