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

## はじめに

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

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

### テスト環境

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

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

## ToggleButton

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

ToggleButton は、CheckBox、RadioButton といった状態を切り替えることができるコントロールの基本クラスです。
CheckBox 外観変更の記事で記載した IsThreeState プロパティは、そもそも ToggleButton で実装されているプロパティで、IsThreeState を true にすることで、 Indeterminate も使用可能となります。

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


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


## サンプル

### 初期デザイン

視認性などの観点で Switch / CheckBox が推奨されて、お目にかからなくなった Android の ToggleButton は、底辺にインジケータを配置して、TextOn / TextOff で指定された文字を ON / OFF で切り替える UI でした。

https://techbooster.org/android/ui/9445/

上記をベースとして、インジケータ表示位置を底辺から、左端にした UI を目指します。

![ToggleButton.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/e1f26947-4bad-4558-be25-7dcd261c5e44.png)

インジケータエリア横幅を 20pixel とします。
インジケータ（indicator）は、コーナーラウンド有りの 1pixel 黒枠で、マージン（3,3,5,3）を設定して、Background は状態値によって配色を変更します。
ON時の表示（textOn）、OFF時の表示（textOff）を indicator 右に配置して、ON / OFF で表示を切り替えます。
IsThreeState="True" で利用される未定義時の表示（textUndefined）も同様に用意します。

::: note
indicator, textOn, textOff, textUndefined という名称を、後述 xaml サンプルコードで利用します。
:::


### 最終デザイン

標準 ToggleButton と、後述サンプルコード CustomToggleButton の実行結果を掲載します。
上段が ToggleButton、下段が CustomToggleButton で、それぞれ「OFF」「ON」「ONでDIsable」を配置しています。

![sample-ToggleButton.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/c1fa3c8e-18eb-4e0c-b67d-50289bcef7d0.png)

下段左は IsThreeState="True" としていて、「Undefined」時の表示を掲載しておきます。

![sample-ToggleButton2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/d022353a-dee6-4288-884d-95af6a3b7711.png)



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

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

![vs-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/d5dcabd2-9389-47fb-98fb-0baf6247fd14.png)


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

![vs-02-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/81c65d2a-6f28-4961-bf76-77b486d1b2b8.png)


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

### namespace 修正

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

- Themes\Generic.xaml
- MainWindow.xaml

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


## サンプルコード

### CustomToggleButton

CustomToggleButton 実装ポイントを記載します。

- DependencyProperty
  - TextOff（OFF時の表示文字）TextOn（ON時の表示文字）を追加
  - IsThreeState="True" で利用可能な TextUndefined（未定義時の表示文字）を追加
- コントール外観
  - 全体を Border として、内部に Grid（横幅 20pixel、* ）を配置
  - Column="0" で「サンプル - 初期デザイン」に基づく Indicator を配置
  - Column="1" に TextOff をバインドした textOff を Opacity="1" で配置
  - Column="1" に TextOn をバインドした textOn を Opacity="0" で配置
  - Column="1" に TextUndefined をバインドした textUndefined を Opacity="0" で配置

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

- Checked 状態
  - indicator.Background 配色変更
  - textOff の Opacity を 0 として非表示
  - textOn の Opacity を 1 として表示
  - textUndefined の Opacity を 0 として非表示
- Indeterminate 状態
  - indicator.Background 配色変更
  - textOff の Opacity を 0 として非表示
  - textOn の Opacity を 0 として非表示
  - textUndefined の Opacity を 1 として表示
- Disable 状態
  - indicator.Background, Background, BorderBrush, Foreground 配色変更
- MouseOver 状態
  - Background 配色変更

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


```xml:Generic.xml
<Style TargetType="{x:Type local:CustomToggleButton}"
    BasedOn="{StaticResource {x:Type ToggleButton}}">
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type local:CustomToggleButton}">
        <Border Width="{TemplateBinding Width}"
                Height="{TemplateBinding Height}"
                Background="{TemplateBinding Background}" 
                BorderBrush="{TemplateBinding BorderBrush}" 
                BorderThickness="{TemplateBinding BorderThickness}">
          <Grid>
            <Grid.ColumnDefinitions>
              <ColumnDefinition Width="20"/>
              <ColumnDefinition Width="*"/>
            </Grid.ColumnDefinitions>
            <!-- Indicator -->
            <Border x:Name="indicator" Grid.Column="0" Margin="3,3,5,3"
                    BorderBrush="Black" Background="Gainsboro"
                    BorderThickness="1" CornerRadius="2"/>
            <!-- 表示文字：Off -->
            <TextBlock x:Name="textOff" Opacity="1"
                       Grid.Column="1" Text="{TemplateBinding TextOff}"
                       HorizontalAlignment="Left" VerticalAlignment="Center"/>
            <!-- 表示文字：On -->
            <TextBlock x:Name="textOn" Opacity="0"
                       Grid.Column="1" Text="{TemplateBinding TextOn}"
                       HorizontalAlignment="Left" VerticalAlignment="Center"/>
            <!-- 表示文字：Undefined -->
            <TextBlock x:Name="textUndefined" Opacity="0"
                       Grid.Column="1" Text="{TemplateBinding TextUndefined}"
                       HorizontalAlignment="Left" VerticalAlignment="Center"/>
          </Grid>
        </Border>
        <ControlTemplate.Triggers>
          <!-- Checked 状態 -->
          <Trigger Property="IsChecked" Value="True">
            <Setter TargetName="indicator" Property="Background" Value="SkyBlue"/>
            <Setter TargetName="textOff" Property="Opacity" Value="0"/>
            <Setter TargetName="textOn" Property="Opacity" Value="1"/>
            <Setter TargetName="textUndefined" Property="Opacity" Value="0"/>
          </Trigger>
          <!-- Indeterminate 状態 -->
          <Trigger Property="IsChecked" Value="{x:Null}">
            <Setter TargetName="indicator" Property="Background" Value="Salmon"/>
            <Setter TargetName="textOff" Property="Opacity" Value="0"/>
            <Setter TargetName="textOn" Property="Opacity" Value="0"/>
            <Setter TargetName="textUndefined" Property="Opacity" Value="1"/>
          </Trigger>
          <!-- Disable 状態 -->
          <Trigger Property="IsEnabled" Value="False">
            <Setter TargetName="indicator" Property="Background" Value="White"/>
            <Setter Property="Background" Value="GhostWhite" />
            <Setter Property="BorderBrush" Value="Gray" />
            <Setter Property="Foreground" Value="Gray"/>
          </Trigger>
          <!-- MouseOver 状態 -->
          <Trigger Property="IsMouseOver" Value="True">
            <Setter Property="Background" Value="LightBlue" />
          </Trigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>
```

```CSharp:CustomToggleButton.cs
namespace WpfApp1.Controls
{
  public class CustomToggleButton : ToggleButton
  {
    // Off 時の表示文字
    public string TextOff
    {
      get { return (string)GetValue(TextOffProperty); }
      set { SetValue(TextOffProperty, value); }
    }
    public static readonly DependencyProperty TextOffProperty =
        DependencyProperty.Register("TextOff", typeof(string),
        typeof(CustomToggleButton), new PropertyMetadata("Off"));
    // On 時の表示文字
    public string TextOn
    {
      get { return (string)GetValue(TextOnProperty); }
      set { SetValue(TextOnProperty, value); }
    }
    public static readonly DependencyProperty TextOnProperty =
        DependencyProperty.Register("TextOn", typeof(string),
        typeof(CustomToggleButton), new PropertyMetadata("On"));
    // Undefined 時の表示文字
    public string TextUndefined
    {
      get { return (string)GetValue(TextUndefinedProperty); }
      set { SetValue(TextUndefinedProperty, value); }
    }
    public static readonly DependencyProperty TextUndefinedProperty =
        DependencyProperty.Register("TextUndefined", typeof(string),
        typeof(CustomToggleButton), new PropertyMetadata("Undefined"));

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

    }
  }
}
```


### メイン画面
メイン画面の 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" Margin="2">
      <Grid.RowDefinitions>
        <RowDefinition Height="1*"/>
        <RowDefinition Height="1*"/>
      </Grid.RowDefinitions>
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="1*"/>
        <ColumnDefinition Width="1*"/>
        <ColumnDefinition Width="1*"/>
      </Grid.ColumnDefinitions>
      <!-- 標準コントール  -->
      <ToggleButton x:Name="btnHoge00" Grid.Column="0" Grid.Row="0"
                    Width="150" Height="40"
                    HorizontalAlignment="Left" VerticalAlignment="Center"
                    Content="〇〇機能" IsThreeState="True"/>
      <ToggleButton x:Name="btnHoge10" Grid.Column="1" Grid.Row="0"
                    Width="150" Height="40"
                    HorizontalAlignment="Left" VerticalAlignment="Center"
                    Content="△△機能" IsChecked="True"/>
      <ToggleButton x:Name="btnHoge20" Grid.Column="2" Grid.Row="0"
                    Width="150" Height="40"
                    HorizontalAlignment="Left" VerticalAlignment="Center"
                    Content="□□機能" IsChecked="True" IsEnabled="False"/>

      <!-- カスタムコントール  -->
      <local:CustomToggleButton x:Name="btnHoge01" Grid.Column="0" Grid.Row="1"
                                Width="150" Height="40"
                                HorizontalAlignment="Left" VerticalAlignment="Center"
                                TextOn="〇〇機能-標準" TextOff="〇〇機能-無効"
                                TextUndefined="〇〇機能-拡張" IsThreeState="True"
                                IsChecked="False"/>
      <local:CustomToggleButton x:Name="btnHoge11" Grid.Column="1" Grid.Row="1"
                                Width="150" Height="40"
                                HorizontalAlignment="Left" VerticalAlignment="Center"
                                TextOn="△△機能-有効" TextOff="△△機能-無効"
                                IsChecked="True"/>
      <local:CustomToggleButton x:Name="btnHoge21" Grid.Column="2" Grid.Row="1"
                                Width="150" Height="40"
                                HorizontalAlignment="Left" VerticalAlignment="Center"
                                TextOn="□□機能-有効" TextOff="□□機能-無効"
                                IsChecked="True" IsEnabled="False"/>
    </Grid>
  </StackPanel>
</Window>
```