WPF リストのチェック機能の実装方法について

# はじめに
WPF でなにかしらのリストをチェックできるような機能を実装する際の手法について備忘録も兼ねて記します。

:::note
MVVM フレームワークとして [Prism](https://github.com/PrismLibrary/Prism) を使用しています。
:::

# 0. Model, ViewModel

```csharp:Item.cs
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 をラップしたクラスを作成します。

```csharp:CheckableItem.cs
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;
    }
}
```

:::note
複数のクラスに対応できるように下記のようなクラスにしておくと便利です

```csharp:Checkable.cs
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

```csharp:MainWindowViewModel.cs
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 を使う

```xml:MainWindow.xaml
<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>
```

![CheckListSample_001.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145898/a257c4b3-eee3-801b-df98-ad46511b7d84.gif)

シンプルに `ItemsControl` で表示するとこんな感じになります。
これを以下のようにもう少しカスタマイズすることもできます。

```xml:MainWindow.xaml
<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>
```

![CheckListSample_002.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145898/4ccc69d7-4f07-fc9d-e2f6-fd79b235c546.gif)

`CheckBox` の上に `Border` が入っているのはマウスをのせた際に背景色を変更するためで、その見た目の切り替えは `Border.Style` の `Trigger` と `CheckBox.Style` の `DataTrigger` で行っています。

# 2. DataGrid を使う

```xml:MainWindow.xaml
<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>
```

![CheckListSample_003.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145898/de308792-7d71-b3de-bd74-a21dc6353463.gif)

まず必要最低限の実装をするとこんな感じになります。
この状態だと

* チェックボックスをクリックした時しかチェックできない(行のどこをクリックした場合でもチェックしたい)
* チェックボックスをクリックするために一度選択状態にしないといけない

などの問題が発生します。

:::note
`DataGrid` で選択状態にしないとチェックボックスが機能しない問題は`DataGridCheckBoxColumn` でなく `DataGridTemplateColumn` を使用して `CheckBox` を配置する方法でも解消することができます。
:::

行のどこをクリックした場合でもチェックできるようにするには、クリックイベントを拾ってクリックした行の `DataContext` の `IsChecked` プロパティにアクセスすればよさそうです。
この処理は View のコードビハインドに直接記述すると汎用性が低くなるので `Behavior` を使用します。

```csharp:CheckDataGirdBahavior.cs
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 に適用するだけです。

```xml:MainWindow.xaml
<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>
```

:::note warn
もとのチェックボックスが機能しないように `IsReadOnly` を True にしてください。
:::

![CheckListSample_004.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145898/6181fe1d-d7bd-cd4f-e680-cc9d55699520.gif)


# 3. ListView を使う

```xml:MainWindow.xaml
<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>
```

![CheckListSample_005.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145898/270ab020-063a-ee5c-66a2-756a1824143c.gif)

こちらは選択状態にしなくてもチェックボックスは機能しますが、
チェックボックスをクリックした時しかチェックできません。
なのでまた `Behavior` で対応します。

```csharp:CheckListViewBahavior.cs
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 に適用します。

```xml:MainWindow.xaml
<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>
```

![CheckListSample_006.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/145898/244dbb13-2640-480a-ba5b-82a2168ec150.gif)

`ListView` には `DataGrid` のような `IsReadOnly` プロパティがありません。
なので `Grid` の中に `CheckBox` と `TextBlock` を重ねて `CheckBox` がクリックされないようにしています。
この実装方法はパワープレイ気味というか、あまり正攻法ではない気がするので、他にいい方法があれば教えてください。

# 終わりに

チェックリストの実装方法を3種類ほど書きましたが、特に要件がなければ `ItemsControl` を使用するのが一番いいと思います。
次点で、ヘッダーが必要とかであれば `DataGrid` を使用するのがよさそうです。