2012年5月8日火曜日

wpf : ItemsControl系のカスタムコントロールでStyleを使わずにコンテナ作成を検出

ItemsControl系のカスタムコントロールにイベントを登録するため、コンテナ作成を検出する方法を調べてみました。 ItemsControlというのはListBox、TabControl、TreeViewなどです。 コンテナというのはそれぞれの項目を表示するための、???Itemという名前が付いたコントロール(ListBoxItem、TabItem、TreeViewItemなど)です。 wpfではItemsControlに様々な型のコンテンツを登録できます。 例えばListBoxに対して、

listBox.ItemsSource = new ObservableCollection<string>()
{
    "文字列1",
    "文字列2",
    "文字列3"
};

という風に文字列のコンテンツを登録できます。 その場合、登録したコンテンツは当然GUIコントロールではありません。 コンテンツを表示するためのコントロールが別に必要になります。 それがコンテナ(ItemContainer)です。

コンテナにイベントハンドラを登録する方法で1番簡単なのは、StyleでのEventSetterの指定でしょう。 例えば、ListBoxのItemContainerStyleプロパティを次のように指定します。

<ListBox.ItemContainerStyle>
    <Style TargetType="ListBoxItem">
        <EventSetter
                Event="PreviewMouseLeftButtonDown"
                Handler="OnItemPreviewMouseLeftButtonDown"
        />
    </Style>
</ListBox.ItemContainerStyle>

Resourcesプロパティで<Style TargetType="ListBoxItem">を書くのでもOK。 普通にItemsControlを使うだけならこういうやり方が一般的だし、こうすべきです。

しかし、カスタムコントロールを作るときにはこの方法を避けたい場合もあります。 それは、

  • コンテンツのデータに対応したプロパティを追加したい。
  • データ構造にあわせた最適化をしたい。
  • 他のコントロールやデータと連携するための処理を追加したい。

など、見た目の拡張を伴わないカスタマイズをする場合です。 そういうときはStyleに触らないならそれに越したことはないでしょう。

カスタムコントロールのStyleは使う側が簡単に上書きできます。 カスタムコントロール側のStyleで↑のような処理を実装してしまうと、カスタムコントロールを使う側に「Styleを使わずにコーディングしなければならない」という制約が付いてしまいます。 Styleを使わずに実装したなら、そういう制約は付きません。

というわけで、Styleを使わない方法について考えることにします。 Styleでイベントハンドラを登録できないならば、コンテナ作成を検出してC#のコードでイベント登録をしなければなりません。 そういうネタについて色々検索してみたら、想定外のことを書いているページが見つかりました。

どうやらListBoxの場合、ListBoxItemは見える範囲+数個だけインスタンス化されて、使いまわされているようですね。 msdnライブラリを見ると、他のコントロールについてもそういう「リサイクル」をするものがけっこうあるようです。 作成のタイミングだけ捕まえてイベントを追加すればいいというお話ではなさそうです。

さらに検索するとこんなページも発見。

なんでどっちも猫なんでしょう?

それは脇に置いといて、独自のItemsControlを作るときのヒントのようです。 コンテナのライフサイクルに関する情報が含まれています。 どうやらカスタムItemsControlで、

  • GetContainerForItemOverride
  • PrepareContainerForItemOverride
  • ClearContainerForItemOverride

あたりのprotectedメソッドをoverrideすればコンテナ作成 or リサイクルのタイミングをつかめそうです。

まずはGetContainerForItemOverrideを見てみましょう。 msdnライブラリの記述は、

  • 解説 ... ItemsControlを継承するクラスは、このメソッドをオーバーライドすることにより、項目コンテナーとして使用される特定の型を取得できます。 GetContainerForItemOverrideをオーバーライドする場合は、GetContainerForItemOverride実装で、基本実装を呼び出す必要があります。

分かりにくいなぁ。 でも、あれ? これだけ抑えればいいんじゃ? 前に挙げた最適化のページと併せて考えると、baseクラスではItemContainerGeneratorを使っているから基本実装は呼ばなければならないとして、

protected override DependencyObject GetContainerForItemOverride()
{
    //ダメ → ListBoxItem res = new ListBoxItem();
    ListBoxItem res = (ListBoxItem)base.GetContainerForItemOverride();
    res.PreviewMouseLeftButtonDown += イベントハンドラ;
    return res;
}

みたいなコードで良さそう...

と思ったらダメでした。 このメソッドはxamlで書かれた項目やItemsControl.Items.Addで追加された項目では利用されません。 ItemsControl.ItemsSourceにObservableCollectionを登録したときは動作しました。 「ItemsSourceを使う」という制約ありならこれでもいいかもしれませんが、普通はダメ。

次はPrepareContainerForItemOverrideとClearContainerForItemOverrideのセット。 これがアタリっぽいですね。 PrepareContainerForItemOverrideのmsdnライブラリの記述は、

  • メソッド概要 ... 指定された項目を表示するために、指定された要素を準備します。
  • 解説 ... 要素の準備には、スタイルの適用、バインドの設定などの作業が含まれます。

こんな感じのコードにすれば良さそうです。

protected override void PrepareContainerForItemOverride(DependencyObject element, object item)
{
    base.PrepareContainerForItemOverride(element, item);

    ListBoxItem listBoxItem = (ListBoxItem)element;
    listBoxItem.PreviewMouseLeftButtonDown += イベントハンドラ;
}

protected override void ClearContainerForItemOverride(DependencyObject element, object item)
{
    ListBoxItem listBoxItem = (ListBoxItem)element;
    listBoxItem.PreviewMouseLeftButtonDown -= イベントハンドラ;

    base.ClearContainerForItemOverride(element, item);
}

PrepareContainerForItemOverrideでは基本実装を呼んだ後にイベントハンドラの登録を、ClearContainerForItemOverrideでは基本実装を呼ぶ前にイベントハンドラの削除をします。 引数はelementがコンテナ、itemがコンテンツです。 コンテンツはItemsSource経由で渡された場合はその型に、xamlで書かれた項目やItemsControl.Items.Addで追加された項目はelementと同じになります。 今回はコンテナにイベントハンドラを登録したいだけなのでelementだけを見ればOK。

試しにカスタムListBoxを作ってイベントハンドラを追加/削除するタイミングでDebug.Printをしてみたら、

  • 最初から表示される項目は即PrepareContainerForItemOverrideで呼ばれる。
  • 表示範囲外にあった項目はスクロールで近づくとPrepareContainerForItemOverrideで呼ばれる。
  • スクロールで表示範囲外に出た項目はしばらく待つとClearContainerForItemOverrideで呼ばれる。

というふうな動きになりました。 参考サイトの記述ともあっているし、これで良いんじゃないですかね?

ということで結論は、

  • ItemsControl系のカスタムコントロールでStyleを使わずにコンテナ作成を検出するにはPrepareContainerForItemOverrideをoverrideする。そこで準備(イベント登録)などを行う。
  • コンテナは使いまわされるので、ClearContainerForItemOverrideで準備した内容を取り消す。

--------

ちなみに、カスタムコントロールを作るのではなくItemsControlを外から見張る場合は、ItemContainerGeneratorのItemsChangedイベントを処理する方法があるようです。 しかしItemContainerGeneratorについては情報が少ないのでよく分かりませんでした。 検索しても良い情報が見つからないし、msdnライブラリにもたいした記述が無いし...

せめてItemsChangedEventArgs.Actionの詳細とかが分かったらなぁ。

ItemContainerGeneratorはsealedクラスなので標準ItemsControlのカスタマイズ用に自作とかも無理っぽいです。 話はそれますが、ItemContainerGeneratorの自作が無理ならカスタムコンテナの作成とかも無理っぽいですね。 カスタムコンテナを使いたい場合は、例えばListBoxを継承してカスタムコントロールを作るのではなく、ItemsControlを継承して1からカスタムListBoxっぽい物を作らないとなりません。 しょっぱい。

ItemContainerGeneratorの他にコンテナ作成を検出する方法は見つかりませんでした。 「これは?」と思ったやつもコンテナのリサイクルには対応してなかったり。

最後に、当然ですがGUIの要素ではなくデータだけ扱うのであればコントロールとは分離した方がいいです。