初めての WinUI 3 (2) ViewとViewModel

# 前回
WinUI 3 の開発環境を整えました。

https://qiita.com/kashin777/items/bd20b26eb80ab9587bfc

# 今回
基礎練です。お試し実装をしてみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515556/78416320-3e36-4e93-80e4-3dc39b593286.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515556/30942f75-1667-4fb3-a34c-56f3b7b6c6e4.png)

## 事前準備
**INotifyPropertyChanged 地獄** から逃れるために、ViewModelのベースを作ります。
.NET Community MVVM Toolkit パイセンお世話になります。

```Csharp ViewModelCommon.cs

    // 検証もしたいので、ObservableObjectじゃなくてObservableValidator
    public partial class ViewModelCommon : ObservableValidator
    {
        // エラーメッセージをプロパティ毎に扱う用
        public Dictionary<string, string> ErrorMessage { get; set; } = new Dictionary<string, string>();

        public ViewModelCommon()
        {
            // キーがないと怒られるので、全部のプロパティを登録
            foreach (var p in GetType().GetProperties().Select(r => r.Name))
            {
                ErrorMessage.Add(p, string.Empty);
            }

            // エラーの状態変更イベントを補足する
            this.ErrorsChanged += ViewModelCommon_ErrorsChanged;
        }

        // エラーの状態が変わったら、ErrorMessageを作り直す
        private void ViewModelCommon_ErrorsChanged(object? sender, DataErrorsChangedEventArgs e)
        {
            if (e.PropertyName != null)
            {
                // エラーメッセージが変更されることを通知
                OnPropertyChanging(nameof(ErrorMessage));

                ErrorMessage[e.PropertyName] = GetErrors(e.PropertyName)
                    .Select(r => r.ErrorMessage).DefaultIfEmpty(string.Empty)
                    .Aggregate((r, l) => $"{r}{Environment.NewLine}{l}")!;

                // エラーメッセージが変更されたことを通知
                OnPropertyChanged(nameof(ErrorMessage));
            }
        }
    }
```

XMALから呼び出し可能なヘルパーメソッドを用意する。

```Csharp ViewHelper.cs

    public class ViewHelper
    {
        private ViewHelper() { }

        // ViewModelの特定プロパティのエラーがあるか判定する
        public static bool HasError(Dictionary<string, string> ErrorMessage, string propertyName)
        {
            return ErrorMessage.ContainsKey(propertyName) && !string.IsNullOrEmpty(ErrorMessage[propertyName]);
        }

        // boolを反転する
        public static bool Invert(bool b)
        {
            return !b;
        }
    }
```

## テーマを切り替えたい
OSの設定ではなく、アプリだけテーマを切り替えて確認したいです。
トグルスイッチでテーマを変更できるようにしてみたいと思います。

### 画面デザイン
ライトモード
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515556/b7a141a2-a1d5-47f6-bbf2-996da040828a.png)
ダークモード
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515556/ecff5f8a-480e-41c3-be3f-3d89152bc02b.png)

### xaml
```xaml MainWindow.xaml
<Grid  Background="{ThemeResource ControlFillColorDefaultBrush}" CornerRadius="{ThemeResource OverlayCornerRadius}" Margin="16 16 16 0" Padding="8 8 8 8">
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="0.1*" MinWidth="50" />
        <ColumnDefinition Width="0.3*" />
        <ColumnDefinition Width="0.5*"  MinWidth="200"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition />
        <RowDefinition />
    </Grid.RowDefinitions>

    <!-- アイコン -->
    <FontIcon Glyph="&#xF19E;" />
    <!-- ラベル -->
    <TextBlock Grid.Column="1" VerticalAlignment="Center" Text="ToggleSwitch &amp; Change Theme" TextTrimming="CharacterEllipsis" />
    <!-- 
      トグルスイッチ 
      トグルスイッチがトグルったらテーマを変更する
      トグルスイッチの状態をViewModelにBindingする
    -->
    <ToggleSwitch x:Name="toggleSwitch" Grid.Column="2" HorizontalAlignment="Right" IsOn="{x:Bind ViewModel.ToggleSwitchValue, Mode=TwoWay}"  Toggled="toggleSwitch_Toggled" />
    <!-- 
      「ライトモード」を表示するInfoBar
      bool値を反転するのヘルパーで出来るのマジカミ。
    -->
    <InfoBar Grid.Row="2" Grid.ColumnSpan="3"
        IsOpen="{x:Bind local:ViewHelper.Invert(ViewModel.ToggleSwitchValue), Mode=OneWay}"
        IsClosable="False"
        Severity="Informational"
        Message="ライトモード" Margin="8 4 0 4" CornerRadius="{ThemeResource ControlCornerRadius}" />
    <!-- 
      「ダークモード」を表示するInfoBar
    -->
    <InfoBar Grid.Row="2" Grid.ColumnSpan="3"
        IsOpen="{x:Bind ViewModel.ToggleSwitchValue, Mode=OneWay}"
        IsClosable="False"
        Severity="Informational"
        Message="ダークモード" Margin="8 4 0 4" CornerRadius="{ThemeResource ControlCornerRadius}" />
</Grid>
```

### View
```Csharp MainWindow.xmal.cs
    public sealed partial class MainWindow : Window
    {
        // ViewModelを宣言しておくと、XAMLでも見える
        public MainWindowViewModel ViewModel { set; get; }

        public MainWindow()
        {
            InitializeComponent();

            // ViewModelを初期化
            ViewModel = new MainWindowViewModel();
            // 現在のテーマを判定
            ViewModel.ToggleSwitchValue = App.Current.RequestedTheme == ApplicationTheme.Dark;
        }

        // トグルスイッチがトグルったらテーマを変更する
        private void toggleSwitch_Toggled(object sender, RoutedEventArgs e)
        {
            var requestTheme = (sender as ToggleSwitch)!.IsOn ? ElementTheme.Dark : ElementTheme.Light;
            FrameworkElement rootElement = (this.Content as FrameworkElement)!;
            if (rootElement.ActualTheme != requestTheme)
            {
                rootElement.RequestedTheme = requestTheme;
            }
        }
    }
```

:::note warn
現状、このWindowの中だけテーマが変わっている感。
アプリ全体のテーマを変える方法がわかりませんでしたので、募集中。
```Application.Current.RequestedTheme = ApplicationTheme.Dark;```とすると、COMExceptionがでるんだが？
:::

### ViewModel

```Csharp MainWindowViewModel.cs
    // ViewModelの共通クラスを継承
    public partial class MainWindowViewModel : ViewModelCommon
    {
        // トグルスイッチの状態
        [ObservableProperty]
        public partial bool ToggleSwitchValue { get; set; };

        public MainWindowViewModel() : base()
        {
        }
    }
```

## 入力内容を検証したい
入力された数値を、検証してみましょう。

### 画面デザイン
初期状態
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515556/ccd68230-5741-4607-9412-c8b85a845abe.png)
範囲外の数値を入力
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515556/2972356a-42a1-42f2-b777-fd17a7a32499.png)

### xaml
```xaml MainWindow.xaml
            <Grid  Background="{ThemeResource ControlFillColorDefaultBrush}" CornerRadius="{ThemeResource OverlayCornerRadius}" Margin="16 16 16 0" Padding="8 8 8 8">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="0.1*" MinWidth="50" />
                    <ColumnDefinition Width="0.3*" />
                    <ColumnDefinition Width="0.5*"  MinWidth="300"/>
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition />
                    <RowDefinition />
                </Grid.RowDefinitions>

                <!-- アイコン -->
                <FontIcon Glyph="&#xE961;" />
                <!-- ラベル -->
                <TextBlock Grid.Column="1" VerticalAlignment="Center" Text="NumberBox &amp; Validation" TextTrimming="CharacterEllipsis" />
                <!--
                  数値入力はNumberBoxを使う
                  数値の形式、検証処理はC＃側で行います。
                -->
                <NumberBox x:Name="numberBox" Grid.Column="2" HorizontalAlignment="Stretch"
                           Header="1～1,000の整数を入力してください。" Value="{x:Bind Path=ViewModel.NumberBoxValue, Mode=TwoWay, UpdateSourceTrigger=LostFocus}"  />
                <!--
                  検証結果にエラーがあればInfoBarで表示する。
                -->
                <InfoBar Grid.Row="2" Grid.ColumnSpan="3"
                    IsOpen="{x:Bind local:ViewHelper.HasError(ViewModel.ErrorMessage, 'NumberBoxValue'), Mode=OneWay}"
                    IsClosable="False"
                    Severity="Error"
                    Message="{x:Bind ViewModel.ErrorMessage['NumberBoxValue'], Mode=OneWay}" Margin="8 4 0 4" CornerRadius="{ThemeResource ControlCornerRadius}" />
            </Grid>
```

### View
```Csharp MainWindow.xmal.cs
    public sealed partial class MainWindow : Window
    {
        // ViewModelを宣言
        public MainWindowViewModel ViewModel { set; get; }

        public MainWindow()
        {
            InitializeComponent();

            // ViewModelを初期化
            ViewModel = new MainWindowViewModel();

            // 数値のフォーマットを指定する。今回は整数。カンマあり。小数部は切り下げ。
            numberBox.NumberFormatter = new DecimalFormatter()
            {
                IntegerDigits = 1,
                FractionDigits = 0,
                NumberRounder = new IncrementNumberRounder() { 
                    Increment= 1,
                    RoundingAlgorithm = RoundingAlgorithm.RoundDown
                },
                IsGrouped = true,
            };
        }
    }
```

### ViewModel

```Csharp MainWindowViewModel.cs
    // ViewModelの共通クラスを継承
    public partial class MainWindowViewModel : ViewModelCommon
    {
        [ObservableProperty]
        // 検証対象にする
        [NotifyDataErrorInfo]
        // 検証内容を設定する
        [Range(1.0, 1000.0, ErrorMessage = "1～1000で入力してください。")]
        public partial double NumberBoxValue { get; set; } = 1;

        public MainWindowViewModel() : base()
        {
        }
    }
```

## 日時を指定したい
入力された日付と時刻を、合わせて表示してみましょう。

### 画面デザイン
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2515556/2052cf23-a2ef-411b-bc70-13b1f1db3f3c.png)

### xaml
```xaml MainWindow.xaml
            <Grid  Background="{ThemeResource ControlFillColorDefaultBrush}" CornerRadius="{ThemeResource OverlayCornerRadius}" Margin="16 16 16 0" Padding="8 8 8 8">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="0.1*" MinWidth="50" />
                    <ColumnDefinition Width="0.3*" />
                    <ColumnDefinition Width="0.5*" />
                </Grid.ColumnDefinitions>
                <Grid.RowDefinitions>
                    <RowDefinition />
                    <RowDefinition />
                </Grid.RowDefinitions>

                <!-- アイコン -->
                <FontIcon Glyph="&#xEC92;" />
                <!-- ラベル -->
                <TextBlock Grid.Column="1" VerticalAlignment="Center" Text="DatePicker &amp; TimePicker" TextTrimming="CharacterEllipsis" />
                <!-- DatePickerとTimePicker -->
                <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
                    <DatePicker x:Name="datePicker" Date="{x:Bind ViewModel.DatePickerValue, Mode=TwoWay}" Header="日付を指定してください。" />
                    <TimePicker x:Name="timePicker" Time="{x:Bind ViewModel.TimePickerValue, Mode=TwoWay}" Header="時間を指定してください。" />
                </StackPanel>
                <!-- 年月日時分形式で表示 -->
                <InfoBar Grid.Row="2" Grid.ColumnSpan="4"
                    IsOpen="True"
                    IsClosable="False"
                    Severity="Informational"
                    Message="{x:Bind ViewModel.DateTimeValue.ToString('yyyy/MM/dd HH:mm', x:Null), Mode=OneWay}" Margin="8 4 0 4" CornerRadius="{ThemeResource ControlCornerRadius}" />
            </Grid>
```

### View
```Csharp MainWindow.xmal.cs
    public sealed partial class MainWindow : Window
    {
        // ViewModelを宣言
        public MainWindowViewModel ViewModel { set; get; }

        public MainWindow()
        {
            InitializeComponent();
            // ViewModelを初期化
            ViewModel = new MainWindowViewModel();
            
            // 日付、時刻を現在時刻で初期化
            var now = DateTimeOffset.Now;
            ViewModel.DatePickerValue = now.Date;
            ViewModel.TimePickerValue = TimeSpan.FromHours(now.Hour).Add(TimeSpan.FromMinutes(now.Minute));
        }
    }
```

### ViewModel

```Csharp MainWindowViewModel.cs
    public partial class MainWindowViewModel : ViewModelCommon
    {

        [ObservableProperty]
        // 日付が変わったらDateTimeValueも変更されることを通知
        [NotifyPropertyChangedFor(nameof(DateTimeValue))]
        public partial DateTimeOffset DatePickerValue { get; set; } = DateTimeOffset.Now.Date;

        [ObservableProperty]
        [NotifyPropertyChangedFor(nameof(DateTimeValue))]
        public partial TimeSpan TimePickerValue { get; set; } = TimeSpan.Zero;

        // 日付と時刻を連結
        public DateTime DateTimeValue
        {
            get
            {
                return DatePickerValue.Date.Add(TimePickerValue);
            }
        }

        public MainWindowViewModel() : base()
        {
        }
    }
```


また、次回があればどうぞ。