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 行目の 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
というインターフェースに括りだして共通化すると更に便利かもしれない。