[WinUI3] 3ステートのCheckBox に Binding できない件へ対応した話

## IsChecked のバインドが途切れてしまう

WinUI3 と MVVM で開発しているアプリで３ステートのチェックボックスを使うことになりました。
チェックボックスは IsThreeState プロパティを True にすることで 選択/非選択/不確定 の3つの状態を表すことができます。
それぞれの状態では IsCheckedプロパティが True/False/null に対応します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/590852/f125e5aa-ab9f-8001-c891-ca746e5111bf.png)

ですが IsCheckedプロパティに bool? の値をバインドしたところ、
バインド直後は動作するのですが、UI上でチェックボックスをクリックするとバインドが外れてしまい、以降はバインドが連動しなくなってしまいました。

```xml:view.xaml
<!-- IsCheckedのバインドが途中で外れてしまう -->
<CheckBox IsChecked="{Binding SelectedState.Value, Mode=OneWay}"
          IsThreeState="True" />
```
```csharp:viewmodel.cs
public ReadOnlyReactiveProperty<bool?> SelectedState { get; }
```

この問題に対して解決できましたので記事にします。

## 原因

Binding の仕様でした。
Binding は null を伝えることができず、そのために発生した不具合でした。

調べたところ WPF の CheckBox でも同じ原因で同じ不具合が発生するようです。


## もし x:Bind が使える場所なら

もし、あなたのアプリの CheckBox コントロールが x:Bind を使える場所にあるなら x:Bind を使いましょう。
それだけで解決です。

x:Bind は nullable のバインドに対応していますので、バインドが外れることなく動作します。

```xml:view.xaml
<!-- x:Bind だとUIをクリックしても外れない -->
<CheckBox IsChecked="{x:Bind SelectedState.Value, Mode=OneWay}"
          IsThreeState="True" />
```

## 3ステートのプロパティと バインディングコンバータでバインドしてみる

しかし、開発中のアプリでは ListView の DataTemplate の中に CheckBox があり、DataTemplate のなかでは x:Bind が使えませんので別の方法が必要でした。

最初に試したのは バインドするプロパティは enum にして、バインディングコンバータで true/false/null に変換してみる方法です。

結論としては、この方法は効果がありませんでした。

```xml:view.xaml
<!-- enum をバインディングコンバータで null に変換。効果はなかった。 -->
<CheckBox IsChecked="{Binding SelectedThreeState.Value, Mode=OneWay, Converter={StaticResource CheckBoxThreeStateConverter}}"
          IsThreeState="True" />
```
```csharp:viewmodel.cs
public ReadOnlyReactiveProperty<CheckBoxThreeStates> SelectedThreeState { get; }
```
```csharp
    // 3ステートを表すenum
    public enum CheckBoxThreeStates
    {
        Unselected,
        Selected,
        Indeterminate
    }
```
```csharp
    // バインディングコンバータ
    internal class CheckBoxThreeStateConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, string language)
        {
            if (value == null) return false;
            return (CheckBoxThreeStates)value switch
            {
                CheckBoxThreeStates.Selected => true,
                CheckBoxThreeStates.Unselected => false,
                CheckBoxThreeStates.Indeterminate => null,
                _ => false
            };
        }

        public object ConvertBack(object value, Type targetType, object parameter, string language)
        {
            throw new NotImplementedException();
        }
    }
```

## カスタムコントロールにする

効果があったのはこの対策でした。

State という依存プロパティを持たせて、これに bool? の値をバインドする。
State の変化時に内部で IsChecked に反映させる。
という間接的なバインドをしたところバインドは外れることなく期待通りに動作しました。

以下のブログを参考にしました。

- https://blog.jerrynixon.com/2014/07/lets-code-in-winrt-xaml-you-cannot.html

```xml:view.xaml
<!-- 期待通りに動いた。Stateに bool? をバインドして使う。 -->
<custom:BindableCheckBox
        State="{Binding SelectedState.Value, Mode=OneWay}"
        IsThreeState="True" />
```
```csharp:viewmodel.cs
public ReadOnlyReactiveProperty<bool?> SelectedState { get; }
```
```csharp:custom control
    public partial class BindableCheckBox : CheckBox
    {
        public BindableCheckBox()
        {
            this.DefaultStyleKey = typeof(BindableCheckBox);
        }

        public static readonly DependencyProperty StateProperty =
            DependencyProperty.Register(
               nameof(State),
               typeof(object),
               typeof(BindableCheckBox),
               new PropertyMetadata(null, (s, e) =>
               {
                   if (s is BindableCheckBox cb)
                   {
                       try
                       {
                           var newState = (bool?)e.NewValue;
                           if (cb.IsChecked != newState)
                               cb.IsChecked = newState;
                       }
                       finally
                       {
                       }
                   }
               }));

        public bool? State
        {
            get { return (bool?)GetValue(StateProperty); }
            set { SetValue(StateProperty, value); }
        }
    }
```

カスタムコントロールなので Generic.xaml に標準スタイルを作成してあげる必要があります。
この辺りはカスタムコントロールの一般的な話なので割愛します。

```xml:Generic.xaml
    <!-- こんな感じでデフォルトスタイルを作成 -->
    <Style TargetType="local:BindableCheckBox" BasedOn="{StaticResource DefaultBindableCheckBoxStyle}" />

    <!-- 標準CheckBoxのスタイルを真似ただけ -->
    <Style x:Key="DefaultBindableCheckBoxStyle" TargetType="local:BindableCheckBox">
        <Setter Property="Background" Value="{ThemeResource CheckBoxBackgroundUnchecked}" />
        <Setter Property="Foreground" Value="{ThemeResource CheckBoxForegroundUnchecked}" />
        <Setter Property="BorderBrush" Value="{ThemeResource CheckBoxBorderBrushUnchecked}" />
        (・・・以下略・・・)
```

## おわりに

私はこの障害（仕様）への対策に１日費やしてしまいました。
この記事が誰かの助けにならんことを。