バインディングに罪はない(よね?)

twitter 経由 id:okazuki:20110128:1296222090 なかんじの。

コンテンツ モデルの復習

WPF のコンテンツ モデルは、非常に面白くて複雑な機能を手軽に利用できる便利なやつである。

var okay = new Button();

okay.Content = "OK";

このような Content プロパティへ文字列を代入するコードは、コンテンツ モデルの解説で頻繁にあらわれる。WinForms でいうところの Text プロパティに相当するのが Content プロパティなのだが Content プロパティはオブジェクト型なので何でも代入できる。そして、文字列を代入すればラベルとして表示され、画像を代入すればボタンの表面に画像が表示されます。
このような「文字列を設定したらラベルとして描画され、画像を設定したら画像が描画される」といった処理を一手に引き受けているのが ContentPresenter 様だ。
ContentPresenter は与えられたデータ ポイントとテンプレートを組み合わせて、文字を描画したり画像を描画したりするために必要なものとそのプロパティを設定してくれる。たとえば、前述の例では ContentPresenter は「TextBlock を生成して、その中身として文字列表現を設定する」という動作になっているため、ボタンの見た目は OK という文字を描画したものになる。
ContentPresenter は凄い奴なんだが、やってくれることは単純かもしれない。

  1. 設定されたコンテンツ オブジェクトの型が UIElement ならば、そのまま子として追加する*1
  2. 設定されたコンテンツ オブジェクトに対応するデータテンプレートがあれば、データテンプレートをインスタンス化して追加する
  3. 設定されたコンテンツ オブジェクトが文字列なら、TextBlock をインスタンス化して追加する
  4. 設定されたコンテンツ オブジェクトが変換によって上記処理を実施できないか試みる
    1. つまり、UIElement に変換できるならば変換し、変換結果を子として追加する
    2. データテンプレートは型に依存しないので変換によるテンプレートの適用は発生しない
    3. 最後に、文字列への変換が定義されていないか確認し、変換できるなら TextBlock を追加する
  5. 最終的に、どうしようもないので ToString() を呼び出して TextBlock を追加する

注意するべきは、ContentPresenter.ContentTemplate は型情報を用いないで採用されることぐらいかな?*2
さらに、ContentPresenter には兄弟と親戚がいる。兄弟が ItemsPresenter で、親戚が ChildPresenter と ChildrenPresenter。兄弟である ItemsPresenter は複数のオブジェクトを対象にする ContentPresenter で、親戚である ChildPresenter と ChildrenPresenter はレイアウト用となる。ここでは ItemsPresenter の話をする。
ContentPresenter は与えられた Content を表示するため、自身の子として UIElement を追加するように動くのに対して、ItemsPresenter は、与えられた複数個の Content に対して個別の ContentPresenter を作成する。実際は個別の ContentPresenter を提供する UIElement を生成する。
ListBox の Items にコンテンツを設定すると ItemsPresenter によって ListBoxItem が生成され、ListBoxItem の ContentPresenter に個別のコンテンツが設定される。あとは前述のとおりで、冒頭の記事であれば、ListBoxItem に設定されたデータテンプレートを利用して Person 型を表現する UIElement が構築されて追加されるわけですね。また、ItemsPresenter は設定されたオブジェクトから個々のコンテンツを取得するために、ICollectionView を使用します。ビューではないものが与えられたら、そのデフォルトビューを取得して使用します。*3

バインディングによって何が設定されるのか

冒頭の記事にもどって、まずは ContentControl にテンプレートがない場合ですが、

  <ListBox
    Grid.Row="0"   
    ItemsSource="{Binding}"  
    ItemTemplate="{StaticResource personViewTemplate}"  
    IsSynchronizedWithCurrentItem="True"/>  
  <ContentControl
    Grid.Row="1"   
    Content="{Binding}"/>  

このとき、ListBox.ItemsSource と ContentControl.Content に設定されるものは何でしょうか?というと、共に people (IEnumerable) です。WPFバインディングエンジンは色々と多機能ですが、WinForms と比較すると、何も指示しないと何もせず、何か指示すると期待以上に働くが指示通りにしか働かないいいやつです。上記のコードではパスが未設定のためバインディングエンジンは何もしないでソースを返します。*4つまり、共に people が設定されます。
さて、復習コーナーで書いたように、ListBox の ItemsPresenter は与えられたオブジェクトがビューではないことを確認すると、CollectionViewSource.GetDefaultView() を呼び出してデフォルトビューを取得し、その中身を ListBoxItem として展開していきます。このため、ListBoxItem の ContentPresenter に設定されるオブジェクトは個々の Person オブジェクトです。この ContentPresenter にはコンテンツテンプレートとして ItemTemplate で指定したテンプレートが設定されているので、ListBox の各項目には名前と年齢が併記された UI が生成されます。
次行の ContentControl にはテンプレートがないため、ContentPresenter は people を UIElement か文字列に変換しようと試みる。しかし、people からそれらに対して変換はできないため、ContentPresenter は ToString() を呼び出して IEnumerable の実装型の名称 "System.Linq.Enumerable..." という文字列を保持した TextBlock を追加します。
では、

  <ContentControl
    Grid.Row="1"   
    ContentTemplate="{StaticResource personViewTemplate}"/> 

と、テンプレートを設定するとどうなるでしょうか? バインディングエンジンも ContentPresenter も、コンテンツテンプレートが指定されていようがいまいが挙動はかわらないため、この ContentControl の ContentPresenter には people が与えられ、ContentTemplate で指定されたテンプレートに従って追加されます。テンプレートの内容は、というと

  <DataTemplate x:Key="personViewTemplate" DataType="{x:Type WpfTemplate:Person}">
    <StackPanel Orientation="Horizontal">
      <TextBlock Text="{Binding Name}"/>
      <TextBlock Text="さん " />
      <TextBlock Text="{Binding Age}"/>
      <TextBlock Text="歳" />
    </StackPanel>
  </DataTemplate>

となっています。テンプレートがインスタンス化されるとき、環境データコンテキストは対象となったコンテンツを示します。つまり、このテンプレート内にあるソースを省略したバインディングの対象は、このテンプレートの基となった ContentPresenter のコンテンツになるということです。何度か書いているように、つまり people が対象になります。
ここまでのバインディングと異なり、このテンプレートでは Path プロパティが設定されているためバインディングエンジンは仕事を行います。最初のバインディングでは Name というプロパティを検索することになっています。バインディングでも、ItemsPresenter と同様にすべてのコレクションはビューに変換してから処理されます。*5つまり、ICollectionView.Name または ICollectionView.CurrentItem.Name を参照するという指定になります。ちなみに、前者は ".Name" 後者は "/Name" と書くのが限定的記述方法なのですが、通常は先頭の "." や "/" は省略して記述されることが多いようです。XAML のサンプルを書いてた人は VB が嫌いだったんですかね?(笑) バインディングエンジンは "." または "/" が省略されると、 ".Name" → "/Name" の順で両方を検索します。"/" は .CurrentItem の省略ですので "/foo/bar" と ".CurrentItem.bar.CurrentItem" のバインディング結果は同じです。今回の場合は、"/Name" (.CurrentItem.Name) がマッチするため、既定のビューの(ListBox から更新している)現在位置にある Person の Name プロパティが手に入ります。

その他

そうそう、DataTemplate で Key を指定しているのに DataType も指定するのは勘違いという不具合の元になりかねないので、やめたほうがいいんじゃないかな〜?とは思います。MSDN でも Key つけたら DataType つけるな、DataType つけたら Key つけるな。とデータテンプレートの基本に書いてあります(微嘘)

ついでに、Key を指定せずに DataType を指定したデータ駆動型のバインディングを行う場合、ContentControl に Person 型を表示するためには、バインディングで明確に Person 型を指定しなければなりません。つまり、{Binding} だけだと IEnumerable (WhereSelectIterator) なので DataType="Person" ではマッチングしないんですね。しかも、x:Type は Generics をサポートしていないし、コード上から typeof(IEnumerable>) やら people.GetType() を設定しても反応してくれません。WORKAROUND として List を指定したい場合には

public class StringList : List<string> { }

などとして、StringList 型を使用するんだそうな……。

*1:追加するってことは、それが描画されて画面に表示されるってことだ

*2:型によってデータテンプレートを切り替える場合、ContentTemplateSelector をそのように実装するか、データ駆動型テンプレートとして登録しておく必要がある

*3:この、ビュー以外を与えるとビューが取得される挙動も、コンテンツ モデルと同様に開発者に透過的に提供されている素晴らしい機能だと思います

*4:MSDN: データ バインディングの概要 > 値へのパスの指定 > オブジェクトの全体にバインドするシナリオうんぬん

*5:MSDN: データ バインディングの概要 > コレクションへのバインド > 既定のビューの使用