アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

WPF の ListView で重複データを正しく登録する

WPF ではバインディングという仕組みによりコレクションとコントロールを簡単に結びつけられる。ただしコレクションに含まれるデータが重複している状態で ListView に関連づけると、アイテム選択がおかしくなる。

本記事ではこの問題を検証しながら対策を試みる。

サンプル プロジェクト

今回のテストに使用したサンプルを公開する。ビルドには Visual Studio 2008 SP1、プログラムの実行には NET Framework 3.5 SP1 が必要。

ListView へ関連付けるデータ

ListView のアイテムとして表示するデータを定義。

namespace WpfListViewSelect
{
    /// <summary>
    /// コンテンツを表すクラスです。
    /// </summary>
    class Content
    {
        public string Title { get; set; }
    }

    /// <summary>
    /// Content クラスを包含するクラスです。
    /// </summary>
    class ContentWrapper
    {
        public ContentWrapper( Content content )
        {
            this._content = content;
        }

        public string Title
        {
            get { return this._content.Title; }
            set { this._content.Title = value; }
        }

        private Content _content;
    }
}

Content は素のデータ、ContentWrapper はそれを所有するデータ クラス。複数の ContentWrapper インスタンスを作成してもコンストラクタに指定した Content が共通ならそちらの参照を共有する。

共通 ListView

問題の有無を同時に見たいので、ウィンドウに 2 つの ListVIew を同時に表示する事にした。その為、共通の ListView をユーザーコントロールとして作成しておく。

<UserControl
  x:Class="WpfListViewSelect.ContentListView"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <ListView ItemsSource="{Binding}">
    <ListView.View>
      <GridView>
        <GridViewColumn Header="タイトル">
          <GridViewColumn.CellTemplate>
            <DataTemplate>
              <TextBlock Text="{Binding Title}"/>
            </DataTemplate>
          </GridViewColumn.CellTemplate>
        </GridViewColumn>
      </GridView>
    </ListView.View>
  </ListView>
</UserControl>

メイン ウィンドウ

データを表示するメイン ウィンドウの XAML は以下のようになる。

<Window
  x:Class="WpfListViewSelect.MainWindow"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:l="clr-namespace:WpfListViewSelect"
  Title="ListView Test" Height="320" Width="320">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition />
      <RowDefinition Height="Auto" />
      <RowDefinition />
    </Grid.RowDefinitions>
    <Grid Grid.Row="0">
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
      </Grid.RowDefinitions>
      <TextBlock Grid.Row="0" Padding="8,0,0,0" Text="正常なリスト" />
      <l:ContentListView Grid.Row="1" DataContext="{Binding Contents}" />
    </Grid>
    <GridSplitter Grid.Row="1" Height="4" HorizontalAlignment="Stretch" />
    <Grid Grid.Row="2">
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
      </Grid.RowDefinitions>
      <TextBlock Grid.Row="0" Padding="8,0,0,0" Text="正しく選択が行えないリスト" />
      <l:ContentListView Grid.Row="1" DataContext="{Binding ProblemContents}" />
    </Grid>
  </Grid>
</Window>

コード ビハインドは以下のように定義する。

namespace WpfListViewSelect
{
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            var data = new MainWindowViewModel();

            // Content を二つ作成
            var one = new Content() { Title = "One" };
            var two = new Content() { Title = "Two" };

            // 問題の発生するリスト
            data.ProblemContents.Add( one );
            data.ProblemContents.Add( two );
            data.ProblemContents.Add( one );
            data.ProblemContents.Add( two );

            // 問題の発生しないリスト
            data.Contents.Add( new ContentWrapper( one ) );
            data.Contents.Add( new ContentWrapper( two ) );
            data.Contents.Add( new ContentWrapper( one ) );
            data.Contents.Add( new ContentWrapper( two ) );

            this.DataContext = data;
            this.InitializeComponent();
        }
    }

    /// <summary>
    /// MainWindow の Model と View を仲介するクラスです。
    /// </summary>
    class MainWindowViewModel
    {
        public MainWindowViewModel()
        {
            this.Contents = new ObservableCollection< ContentWrapper >();
            this.ProblemContents = new ObservableCollection< Content >();
        }

        public ObservableCollection< ContentWrapper > Contents { get; private set; }
        public ObservableCollection< Content > ProblemContents { get; private set; }

    }
}

プログラムの実行

ここまでの実装を行ったプログラムを実行すると以下のようなウィンドウが表示される。

テストプログラムの実行

試しにそれぞれの ListView に対して

  1. 1 行目の One を選択
  2. 3 行目の One を選択

という手順を試してみよう。すると「正常なリスト」の方は正常に選択が行えるが「正しく選択が行えないリスト」の方は 3 行目の One を選択できない事が確認できる。Two の方でも同様の問題が発生する。

原因と対策

この問題は ListView がアイテムの固有識別において関連づけられたオブジェクトのインスタンス参照 (ポインター) を利用している事が原因と思われる。

.NET の参照型はインスタンスを複製しても参照情報を共有するため ListView に関連付けたコレクションに同じインスタンスが重複してる場合、すべて同一に見えるのだろう。

逆に参照が異なれば固有のアイテムとして扱われる。よって同一データを区別したいならデータのインスタンスを直に指定せず、同じインスタンスを入れた「個別の入れ物」を指定すれば良い事が分かる。

サンプルの Content と ContentWrapper はそういう関係になっている。本当のデータは Content インスタンスだがコレクション挿入時に CntentWrapper インスタンスを生成。その中へ Content インスタンスを入れている。

改良

このプログラムでは、単に ListView のアイテム管理の対策を行っているだけで、実運用では更なる改良が必要である。

例えば Content の Title が編集された場合、それを持つ ContentWrapper の変更として通知できないと ListView の表示が更新されなくなってしまう。

これを解決するには以下のような実装を行う。

  1. INotifyPropertyChanged インターフェースを Content と ContentWrapper の両方に実装
  2. ContentWrapper は Content の PropertyChanged ハンドラを実装
  3. Content 編集により ContentWrapper の PropertyChanged ハンドラが呼び出される
  4. ContentWrapper は自分の通知として PropertyChanged を呼び出す
  5. ContentWrapper を所有している ListView に変更が反映される

Content と ContentWrapper のプロパティ名がずれていると厄介。なので通知を行いたいプロパティを IContent というインターフェースに括りだして共通化すると更に便利かもしれない。