WPF - カスタムコントール - CheckBox 外観変更

## はじめに

WPF カスタムコントールを用いることで、手軽に標準コントールの外観/挙動を変更することができます。
本記事では、CheckBox 外観変更について記載します。

**WPF カスタムコントール については下記記事もあります**
- [WPF - カスタムコントール - ContextMenu で一覧選択](https://qiita.com/chai0917/items/63458e17b29c8a4ca2c9)
- [WPF - カスタムコントール - ToggleButton 外観変更](https://qiita.com/chai0917/items/918e7d121222b3f698ff)


### テスト環境

ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。
- WPF - .NET Framework 4.8
- WPF - .NET 8

::: note

記載したソースコードは .NET 8 ベースとしています。
.NET Framework 4.8 の場合は、コメントで記載している null 許容参照型の明示 ? を削除してください。
:::

::: note
Visual Studio 2022 - .NET Framework 4.8 は、C# 7.3 が既定です。
このため、サンプルコードは、C# 7.3 機能範囲で記述しています。
:::


## CheckBox

CheckBox 状態値、通常は Checked / Unchecked の２パターンですが、IsThreeState プロパティを true にすることで、 Indeterminate も使用可能となります。

| 状態                   | IsChecked プロパティ値 |
| ---------------------- | --------------------- |
| Checked（選択）         | true                  |
| Unchecked（非選択）     | false                 |
| Indeterminate（不確定） | null                  |


IsThreeState = false 時、CheckBox クリック操作は、Checked, Unchecked トグル動作です。
IsThreeState = true 時、Checked, Indeterminate, Unchecked 循環トグル動作となります。



## サンプル

### 初期デザイン

CheckBox カスタムコントールとして、下記外観を考えてみます。

![CheckBox-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/e71141ab-3573-4fa9-821d-54812f4ac092.png)

横 19pixel 縦 20pixel（mainBody）を、レイアウトエリアとします。
枠部分（innerBorder）は 14 x 14 の正方形です。 
Checked マーク（markChecked）は、実際に動作させて調整することにします。


::: note
mainBody, innerBorder, markChecked という名称を、後述 xaml サンプルコードで利用します。
:::

### 最終デザイン

標準 CheckBox と、後述サンプルコード CustomCheckBox の実行結果を掲載します。
上段が CheckBox、下段が CustomCheckBox です。

![CheckBox-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/39d4065c-9875-4134-8097-f98c50ae30ac.png)



## サンプルプロジェクト

### カスタムコントール追加
Visual Studio で WPF アプリケーションのプロジェクト WpfApp1 を作成して、ソリューションエクスプローラで Controls というサブフォルダを用意します。
この Controls というサブフォルダを選択して、`追加` - `新しい項目` を選択します。

![vs-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/7cece241-2722-4a4e-985d-2d8be50fdd30.png)


新しい項目追加で `カスタムコントール（WPF）`を選択して、`CustomCheckBox.cs` を作成します。

![vs-02-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/87329b65-e2e4-48c4-98a2-a41a1761c1e1.png)

カスタムコントールを追加すると `Theme\Generic.xaml` が自動的に追加されます。

### namespace 修正

カスタムコントールを Controls 配下に配置したので、下記２ファイルの local namespece を修正します。

- Themes\Generic.xaml
- MainWindow.xaml

```
xmlns:local="clr-namespace:WpfApp1"
 ↓
xmlns:local="clr-namespace:WpfApp1.Controls"
```


## サンプルコード

### CustomCheckBox
外観は、BulletDecorator を利用します。
このコントロールには Bullet と Child の2つのコンテンツプロパティがあります。
- Bullet
  - CheckBox/RadioButtn 選択状態、箇条書きのマーカー/アイコン
- Child
  - 選択肢内容、箇条書き内容

https://learn.microsoft.com/ja-jp/dotnet/api/system.windows.controls.primitives.bulletdecorator

BulletDecorator.Bullet で CheckBox 選択状態の外観をデザインします。

- レイアウト
  - 前述「サンプル - 初期デザイン」横 19pixel 縦 20pixel を Grid で定義
  - 縦方向
    - 最小 20pixel
    - `Height="{TemplateBinding Height}"` として、センタリングするため、最上下端 RowDefinition を `Height="1*"`（単純に `Height="20" VerticalAlignment="Center"` の場合、CustomCheckBox に Height 指定時センタリングされなかったため）
    - 上余白、正方形枠、下余白、それぞれの Height を 4, 14, 2 pixcel
  - 横方向
    - 19pxcel
    - 左余白、正方形枠、右余白、それぞれの Width を 1, 14, 4 pixcel
- mainBody
  - レイアウト 行1列0 から3行3列のエリア
  - Pressed 状態で表示する Border を Transparent で指定
- innerBorder
  - レイアウト 行2列1 から1行1列のエリア
  - 正方形の枠
- markChecked
  - レイアウト 行1列0 から3行3列のエリア
  - Path で指定座標を折れ線で描画
  - Opacity="0" で透明
- markIndeterminate
  - レイアウト 行2列1 から1行1列のエリア
  - 文字 X を中央配置
  - Opacity="0" で透明

BulletDecorator.Child で CheckBox 選択肢内容の外観をデザインします。

- contentText
  - ContentPresenter だと、Foreground 指定ができないので、TextBlock 利用

ControlTemplate.Triggers では下記を指定します。

- Checked 状態
  - markChecked の Opacity を 1 として表示
- Indeterminate 状態
  - markIndeterminate の Opacity を 1 として表示
- Press 状態
  - mainBody の Background を指定値として表示
- Disable 状態
  - markChecked, contentText, innerBorder 配色変更

::: note
特定の条件でのプロパティ変更として ControlTemplate.Triggers を利用しましたが、VisualStateManager を利用することもできます。 
VisualStateManager は、より柔軟な状態管理を提供する仕組みで、特にアニメーションを伴う UI の変更に適しています。
:::

```xml:Generic.xaml
<!-- CustomCheckBox -->
<Style TargetType="{x:Type local:CustomCheckBox}"
       BasedOn="{StaticResource {x:Type CheckBox}}">
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type local:CustomCheckBox}">
        <BulletDecorator>
          <BulletDecorator.Bullet>
            <Grid Height="{TemplateBinding Height}" MinHeight="20" Width="19">
              <Grid.RowDefinitions>
                <RowDefinition Height="1*"/>
                <RowDefinition Height="4"/>
                <RowDefinition Height="14"/>
                <RowDefinition Height="2"/>
                <RowDefinition Height="1*"/>
              </Grid.RowDefinitions>
              <Grid.ColumnDefinitions>
                <ColumnDefinition Width="1"/>
                <ColumnDefinition Width="14"/>
                <ColumnDefinition Width="4"/>
              </Grid.ColumnDefinitions>
              <!-- CheckBox レイアウトエリア -->
              <Border Name="mainBody"
                      Grid.Row="1" Grid.Column="0" Grid.RowSpan="3" Grid.ColumnSpan="3"
                      CornerRadius="4" BorderThickness="1"
                      BorderBrush="Transparent" Background="Transparent"/>
              <!-- CheckBox の Box  -->
              <Border Name="innerBorder"
                      Grid.Row="2" Grid.Column="1"
                      BorderThickness="1"
                      BorderBrush="Gray" Background="Transparent"/>
              <!-- Checked マーク -->
              <Path Name="markChecked"
                    Grid.Row="1" Grid.Column="0" Grid.RowSpan="3" Grid.ColumnSpan="3"
                    Opacity="0"
                    StrokeThickness="4" Stroke="SkyBlue">
                <Path.Data>
                  <PathGeometry>
                    <PathFigure IsClosed="False" StartPoint="4,10">
                      <LineSegment Point="9,14"/>
                      <LineSegment Point="18,1"/>
                    </PathFigure>
                  </PathGeometry>
                </Path.Data>
              </Path>
              <!-- Indeterminate マーク -->
              <TextBlock Name="markIndeterminate"
                         Grid.Row="2" Grid.Column="1"
                         Opacity="0" Margin="0,0,0,2"
                         HorizontalAlignment="Center" VerticalAlignment="Center"
                         Text="×"
                         FontSize="14" FontFamily="Meiryo UI"
                         Foreground="#808080" Focusable="False"/>
            </Grid>
          </BulletDecorator.Bullet>
          <BulletDecorator.Child>
            <TextBlock x:Name="contentText"
                       HorizontalAlignment="Left" VerticalAlignment="Center" 
                       Text="{TemplateBinding Content}"/>
          </BulletDecorator.Child>
        </BulletDecorator>

        <ControlTemplate.Triggers>
          <!-- Checked 状態 -->
          <Trigger Property="IsChecked" Value="True">
            <Setter TargetName="markChecked" Property="Opacity" Value="1"/>
          </Trigger>
          <!-- Indeterminate 状態 -->
          <Trigger Property="IsChecked" Value="{x:Null}">
            <Setter TargetName="markIndeterminate" Property="Opacity" Value="1"/>
          </Trigger>
          <!-- Pressed 状態 -->
          <Trigger Property="IsPressed" Value="True">
            <Setter TargetName="mainBody" Property="Background" Value="LightCyan"/>
          </Trigger>
          <!-- Disable 状態 -->
          <Trigger Property="IsEnabled" Value="False">
            <Setter TargetName="markChecked" Property="Stroke" Value="LightSlateGray"/>
            <Setter TargetName="contentText" Property="Foreground" Value="DimGray"/>
            <Setter TargetName="innerBorder" Property="Background" Value="Gainsboro"/>
            <Setter TargetName="innerBorder" Property="BorderBrush" Value="Silver"/>
          </Trigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>
```

```CSharp:CustomCheckBox.cs
namespace WpfApp1.Controls
{
  public class CustomCheckBox : CheckBox
  {
    // 静的コンストラクタ - クラス全体の初期化で１度だけ呼び出される
    static CustomCheckBox()
    {
      // DefaultStyleKeyの設定
      DefaultStyleKeyProperty.OverrideMetadata(typeof(CustomCheckBox), 
          new FrameworkPropertyMetadata(typeof(CustomCheckBox)));
    }
    // インスタンス コンストラクタ - インスタンスごとに呼び出される
    public CustomCheckBox()
    {

    }
  }
}
```

### メイン画面
メイン画面の xaml も掲載しておきます。

```xml:MainWindow.xaml
<Window x:Class="WpfApp1.MainWindow"
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
  xmlns:local="clr-namespace:WpfApp1.Controls"
  mc:Ignorable="d"
  Title="MainWindow" Height="200" Width="600">
  <StackPanel>
    <Grid Height="120">
      <Grid.RowDefinitions>
        <RowDefinition Height="1*"/>
        <RowDefinition Height="1*"/>
      </Grid.RowDefinitions>
      <Grid.ColumnDefinitions>
      <ColumnDefinition Width="1*"/>
        <ColumnDefinition Width="1*"/>
        <ColumnDefinition Width="1*"/>
      </Grid.ColumnDefinitions>
      <!-- 標準コントール  -->
      <CheckBox x:Name="cbHoge00" Grid.Column="0" Grid.Row="0"
                IsThreeState="True" Content="選択状態"
                HorizontalAlignment="Left" VerticalAlignment="Center"
                IsChecked="True"/>
      <CheckBox x:Name="cbHoge10" Grid.Column="1" Grid.Row="0"
                IsThreeState="True" Content="不確定状態"
                HorizontalAlignment="Left" VerticalAlignment="Center"
                IsChecked="{x:Null}"/>
      <CheckBox x:Name="cbHoge20" Grid.Column="2" Grid.Row="0"
                IsThreeState="True" Content="無効状態"
                HorizontalAlignment="Left" VerticalAlignment="Center"
                IsChecked="True" IsEnabled="False"/>
      <!-- カスタムコントール  -->
      <local:CustomCheckBox x:Name="cbHoge01" Grid.Column="0" Grid.Row="1"
          IsThreeState="True" Content="選択状態"
          HorizontalAlignment="Left" VerticalAlignment="Center"
          IsChecked="True"/>
      <local:CustomCheckBox x:Name="cbHoge11" Grid.Column="1" Grid.Row="1"
          IsThreeState="True" Content="不確定状態"
          HorizontalAlignment="Left" VerticalAlignment="Center"
          IsChecked="{x:Null}"/>
      <local:CustomCheckBox x:Name="cbHoge21" Grid.Column="2" Grid.Row="1"
          IsThreeState="True" Content="無効状態"
          HorizontalAlignment="Left" VerticalAlignment="Center"
          IsChecked="True" IsEnabled="False"/>
    </Grid>
  </StackPanel>
</Window>
```