WPF ではバインディングという仕組みによって、コレクション データとコントロールを簡単に結びつけられる。
ただし、コレクションに含まれるデータに重複がある場合、それらを ListView に関連付けるとアイテムの選択が正しく行えなくなる。
以下に、この問題の検証と対策を行うコードを書き出してみる。
サンプル プロジェクト
今回のテストに使用したサンプル プロジェクト一式を以下に公開する。
WpfListViewSelect
サンプル プロジェクトのビルドには 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 は Content を所有するデータ クラスとなる。複数の ContentWrapper インスタンスを作成しても、コンストラクタに指定した Content インスタンスが共通なら、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 行目の One を選択 → 3 行目の One を選択」という手順を試してみよう。
すると「正常なリスト」の方は正常に選択が行えるが、「正しく選択が行えないリスト」の方は、3 行目の One を選択できない事が確認できる。Two の方でも同様の問題が発生する。
原因と対策
この問題は ListView がアイテムの固有識別に、関連付けられたオブジェクトのインスタンス参照を利用している事が原因と思われる。
.NET の参照型は、インスタンスを複製しても参照情報を共有するので、ListView に関連付けたコレクションに同じインスタンスが重複してる場合、それらは全て同一に見えるのだろう。
逆に、参照が異なれば固有のアイテムとして扱われるので、同一のデータを個別に分ける場合は、データのインスタンスを直接指定するのではなく、同じインスタンスを入れた「個別の入れ物」を指定すれば良い事が分かる。
サンプルの Content と ContentWrapper は、そういう関係になっている。本当のデータは Content インスタンスだが、コレクションに入れる時は CntentWrapper インスタンスを毎回生成し、その中に Content インスタンスを入れている。
改良
このプログラムでは、単に ListView のアイテム管理の対策を行っているだけで、実際に運用する場合は、更なる改良が必要である。
例えば Content の Title が編集された場合、それを持つ ContentWrapper の変更として通知できないと、ListView の表示が更新されなくなってしまう。
これを解決するには、以下のような実装を行う。
- INotifyPropertyChanged インターフェースを Content と ContentWrapper の両方に実装
- ContentWrapper は Content の PropertyChanged ハンドラを実装する
- Content の編集により、ContentWrapper の PropertyChanged ハンドラが呼び出される
- ContentWrapper は自分の通知として PropertyChanged を呼び出す
- ContentWrapper を所有している ListView に変更が反映される
Content と ContentWrapper のプロパティ名がずれていると厄介なので、通知を行いたいプロパティを IContent というインターフェースに括りだし、共通化すると更に便利かもしれない。
