はじめに
WPF でなにかしらのリストをチェックできるような機能を実装する際の手法について備忘録も兼ねて記します。
MVVM フレームワークとして Prism を使用しています。
0. Model, ViewModel
public class Item : BindableBase
{
private string name;
public string Name
{
get { return name; }
set { SetProperty(ref name, value); }
}
private decimal price;
public decimal Price
{
get { return price; }
set { SetProperty(ref price, value); }
}
}
こんな Model があるとして、この Model を表示してチェックできるようにしていきます。
まず、この状態ではチェックの状態を保持するプロパティが存在しないしないためこの Model をラップしたクラスを作成します。
public class CheckableItem : BindableBase
{
private bool isChecked;
public bool IsChecked
{
get { return isChecked; }
set { SetProperty(ref isChecked, value); }
}
public Item Item { get; }
public CheckableItem(Item item)
{
Item = item;
}
}
複数のクラスに対応できるように下記のようなクラスにしておくと便利です
public class Checkable<T> : BindableBase
{
private bool isChecked;
public bool IsChecked
{
get { return isChecked; }
set { SetProperty(ref isChecked, value); }
}
public T Source { get; }
public Checkable(T source)
{
Source = source;
}
}
そして MainWindow の ViewModel
public class MainWindowViewModel : BindableBase
{
public List<Item> Goods { get; }
public List<CheckableItem> CheckableGoods { get; }
public MainWindowViewModel()
{
Goods = Enumerable.Range(1, 6).Select(i =>
{
return new Item()
{
Name = $"商品{i}",
Price = i * 1000,
};
}).ToList();
CheckableGoods = Goods.Select(x => new CheckableItem(x)).ToList();
}
}
ここでは Item
クラスのリストを初期化して CheckableItem
のリストに変換しています
あとは View で表示できるようにするだけです。
1. ItemsControl を使う
<ItemsControl ItemsSource="{Binding CheckableGoods}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsChecked}">
<TextBlock Text="{Binding Item.Name}"/>
</CheckBox>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
シンプルに ItemsControl
で表示するとこんな感じになります。
これを以下のようにもう少しカスタマイズすることもできます。
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding CheckableGoods}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border x:Name="border" Padding="2">
<CheckBox IsChecked="{Binding IsChecked}" HorizontalContentAlignment="Stretch">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<TextBlock Text="{Binding Item.Name}"/>
<TextBlock Grid.Column="1" Text="{Binding Item.Price}"/>
</Grid>
<CheckBox.Style>
<Style TargetType="CheckBox">
<Style.Triggers>
<DataTrigger Binding="{Binding IsMouseOver, ElementName=border}" Value="True">
<Setter Property="Foreground" Value="White"/>
</DataTrigger>
</Style.Triggers>
</Style>
</CheckBox.Style>
</CheckBox>
<Border.Style>
<Style TargetType="Border">
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="Black"/>
</Trigger>
</Style.Triggers>
</Style>
</Border.Style>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
CheckBox
の上に Border
が入っているのはマウスをのせた際に背景色を変更するためで、その見た目の切り替えは Border.Style
の Trigger
と CheckBox.Style
の DataTrigger
で行っています。
2. DataGrid を使う
<DataGrid ItemsSource="{Binding CheckableGoods}" AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridCheckBoxColumn Header="IsChecked" Binding="{Binding IsChecked}"/>
<DataGridTextColumn Header="Name" Width="*" Binding="{Binding Item.Name}" IsReadOnly="True"/>
<DataGridTextColumn Header="Price" Width="*" Binding="{Binding Item.Price}" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
まず必要最低限の実装をするとこんな感じになります。
この状態だと
- チェックボックスをクリックした時しかチェックできない(行のどこをクリックした場合でもチェックしたい)
- チェックボックスをクリックするために一度選択状態にしないといけない
などの問題が発生します。
DataGrid
で選択状態にしないとチェックボックスが機能しない問題はDataGridCheckBoxColumn
でなく DataGridTemplateColumn
を使用して CheckBox
を配置する方法でも解消することができます。
行のどこをクリックした場合でもチェックできるようにするには、クリックイベントを拾ってクリックした行の DataContext
の IsChecked
プロパティにアクセスすればよさそうです。
この処理は View のコードビハインドに直接記述すると汎用性が低くなるので Behavior
を使用します。
public class CheckDataGirdBahavior : Behavior<DataGrid>
{
public string IsCheckedPropertyName
{
get { return (string)GetValue(IsCheckedPropertyNameProperty); }
set { SetValue(IsCheckedPropertyNameProperty, value); }
}
// Using a DependencyProperty as the backing store for IsCheckedPropertyName. This enables animation, styling, binding, etc...
public static readonly DependencyProperty IsCheckedPropertyNameProperty =
DependencyProperty.Register("IsCheckedPropertyName", typeof(string), typeof(CheckDataGirdBahavior), new PropertyMetadata("IsChecked"));
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.PreviewMouseLeftButtonDown += DataGrid_PreviewMouseLeftButtonDown;
}
protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.PreviewMouseLeftButtonDown -= DataGrid_PreviewMouseLeftButtonDown;
}
private void DataGrid_PreviewMouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.Source is not DependencyObject source)
{
return;
}
var window = Window.GetWindow(source);
var pos = e.GetPosition(window);
var hitTestResult = VisualTreeHelper.HitTest(window, pos);
if (hitTestResult is null)
{
return;
}
var visualHit = hitTestResult.VisualHit;
while (visualHit != null)
{
if (visualHit is DataGridRow)
{
break;
}
visualHit = VisualTreeHelper.GetParent(visualHit);
}
if (visualHit is not DataGridRow target)
{
return;
}
var dataContext = target.DataContext;
var type = dataContext.GetType();
try
{
var prop = type.GetProperty(IsCheckedPropertyName);
bool isChecked = (bool)prop.GetValue(dataContext);
prop.SetValue(target.DataContext, !isChecked);
}
catch (Exception)
{
throw;
}
}
}
やっていることは単純で PreviewMouseLeftButtonDown
イベントでクリック位置の DataGridRow
を取得してリフレクションで IsChecked
プロパティの値を反転させています。
また、 IsCheckedPropertyName
という依存関係プロパティを定義することでプロパティ名が IsChecked
以外の場合にも対応できるようにしています。
あとはこれを View に適用するだけです。
<DataGrid ItemsSource="{Binding CheckableGoods}" AutoGenerateColumns="False" IsReadOnly="True">
<i:Interaction.Behaviors>
<be:CheckDataGirdBahavior/>
</i:Interaction.Behaviors>
<DataGrid.Columns>
<DataGridCheckBoxColumn Header="IsChecked" Binding="{Binding IsChecked}"/>
<DataGridTextColumn Header="Name" Width="*" Binding="{Binding Item.Name}"/>
<DataGridTextColumn Header="Price" Width="*" Binding="{Binding Item.Price}"/>
</DataGrid.Columns>
</DataGrid>
もとのチェックボックスが機能しないように IsReadOnly
を True にしてください。
3. ListView を使う
<ListView ItemsSource="{Binding CheckableGoods}">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>
</ListView.ItemContainerStyle>
<ListView.View>
<GridView>
<GridViewColumn Header="IsChecked" Width="100">
<GridViewColumn.CellTemplate>
<DataTemplate>
<CheckBox IsChecked="{Binding IsChecked}" HorizontalAlignment="Center"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="Name" Width="100">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Item.Name}" TextAlignment="Center"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="Price" Width="100">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Item.Price}" TextAlignment="Center"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
こちらは選択状態にしなくてもチェックボックスは機能しますが、
チェックボックスをクリックした時しかチェックできません。
なのでまた Behavior
で対応します。
public class CheckListViewBahavior : Behavior<ListView>
{
public string IsCheckedPropertyName
{
get { return (string)GetValue(IsCheckedPropertyNameProperty); }
set { SetValue(IsCheckedPropertyNameProperty, value); }
}
// Using a DependencyProperty as the backing store for IsCheckedPropertyName. This enables animation, styling, binding, etc...
public static readonly DependencyProperty IsCheckedPropertyNameProperty =
DependencyProperty.Register("IsCheckedPropertyName", typeof(string), typeof(CheckListViewBahavior), new PropertyMetadata("IsChecked"));
protected override void OnAttached()
{
base.OnAttached();
AssociatedObject.PreviewMouseLeftButtonDown += ListView_PreviewMouseLeftButtonDown;
}
protected override void OnDetaching()
{
base.OnDetaching();
AssociatedObject.PreviewMouseLeftButtonDown -= ListView_PreviewMouseLeftButtonDown;
}
private void ListView_PreviewMouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.Source is not DependencyObject source)
{
return;
}
var window = Window.GetWindow(source);
var pos = e.GetPosition(window);
var hitTestResult = VisualTreeHelper.HitTest(window, pos);
if (hitTestResult is null)
{
return;
}
var visualHit = hitTestResult.VisualHit;
while (visualHit != null)
{
if (visualHit is ListViewItem)
{
break;
}
visualHit = VisualTreeHelper.GetParent(visualHit);
}
if (visualHit is not ListViewItem target)
{
return;
}
var dataContext = target.DataContext;
var type = dataContext.GetType();
try
{
var prop = type.GetProperty(IsCheckedPropertyName);
bool isChecked = (bool)prop.GetValue(dataContext);
prop.SetValue(target.DataContext, !isChecked);
}
catch (Exception)
{
throw;
}
}
}
これもやっていることは、取得する対象が ListViewItem
になっているという点以外は DataGrid
の時と同じです。
あとは同様に View に適用します。
<ListView ItemsSource="{Binding CheckableGoods}">
<i:Interaction.Behaviors>
<be:CheckListViewBahavior/>
</i:Interaction.Behaviors>
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
</Style>
</ListView.ItemContainerStyle>
<ListView.View>
<GridView>
<GridViewColumn Header="IsChecked" Width="100">
<GridViewColumn.CellTemplate>
<DataTemplate>
<Grid>
<CheckBox IsChecked="{Binding IsChecked}" HorizontalAlignment="Center"/>
<TextBlock/>
</Grid>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="Name" Width="100">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Item.Name}" TextAlignment="Center"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
<GridViewColumn Header="Price" Width="100">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock Text="{Binding Item.Price}" TextAlignment="Center"/>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
ListView
には DataGrid
のような IsReadOnly
プロパティがありません。
なので Grid
の中に CheckBox
と TextBlock
を重ねて CheckBox
がクリックされないようにしています。
この実装方法はパワープレイ気味というか、あまり正攻法ではない気がするので、他にいい方法があれば教えてください。
終わりに
チェックリストの実装方法を3種類ほど書きましたが、特に要件がなければ ItemsControl
を使用するのが一番いいと思います。
次点で、ヘッダーが必要とかであれば DataGrid
を使用するのがよさそうです。