【C#/WPF】MVVMパターンで別ウィンドウで加工したデータを受け取る方法

【C#/WPF】MVVMパターンで別ウィンドウで加工したデータを受け取る方法

はじめに

前回は MVVM 構成で親ウィンドウからパラメーターを渡し、別ウィンドウを表示するところまで実装しました。

今回はその受け取ったパラメーターをサブウィンドウ側で編集し、加工した値を受け取って親ウィンドウに反映するところまで実装していきたいと思います。

実装した機能のイメージです。

show-subwindow-edit.gif

IWindowService インターフェースにメソッドを追加

IWindowServiceクラスにShowWindowWithCallbackというメソッドを追加します。

これは、以前実装したShowWindowメソッドにコールバック関数として、Action デリゲートを追加したものになります。

また、サブ画面を閉じるメソッドも追加します。

"Services\IWindowService.cs"
using System.Windows;

namespace MVVM.MultiWindowSample.Servicies
{
    public interface IWindowService
    {
        // 省略

        /// <summary>
        /// サブ画面を表示し、結果をコールバックで返す
        /// </summary>
        /// <typeparam name="TWindow">Window</typeparam>
        /// <typeparam name="TViewModel">Windowと関連したViewModel</typeparam>
        /// <typeparam name="TResult">戻り値の型</typeparam>
        /// <param name="parameter">パラメータ</param>
        /// <param name="resultCallback">結果を受け取るコールバック</param>
        /// <param name="owner">親ウィンドウ</param>
        void ShowWindowWithCallback<TWindow, TViewModel, TResult>(
            object? parameter = null,
            Window? owner = null,
            Action<TResult?>? resultCallback = null) where TWindow : Window, new()
                                                     where TViewModel : class;

        /// <summary>
        /// 指定されたウィンドウを閉じる
        /// </summary>
        /// <param name="window">閉じるウィンドウ</param>
        void CloseWindow(Window window);

    }
}

作成したメソッドをWindowServiceに追加で実装します。

"Services/WindowService"
/// <summary>
/// ウィンドウの表示や操作を管理するサービスクラス
/// </summary>
public class WindowService : IWindowService
{
    // 省略

    /// <summary>
    /// 指定されたウィンドウとビューモデルを使用して新しいウィンドウを表示し、結果をコールバックで返す
    /// </summary>
    /// <typeparam name="TWindow">表示するウィンドウの型</typeparam>
    /// <typeparam name="TViewModel">使用するビューモデルの型</typeparam>
    /// <typeparam name="TResult">結果の型</typeparam>
    /// <param name="parameter">ビューモデルに渡すパラメータ(オプション)</param>
    /// <param name="resultCallback">結果を受け取るコールバック関数(オプション)</param>
    /// <param name="owner">親ウィンドウ(オプション)</param>
    public void ShowWindowWithCallback<TWindow, TViewModel, TResult>(
        object? parameter = null,
        Action<TResult?>? resultCallback = null,
        Window? owner = null)
        where TWindow : Window, new()
        where TViewModel : class
    {
        try
        {
            // 新しいウィンドウを作成
            var window = new TWindow();
            var viewModel = _serviceProvider.GetService(typeof(TViewModel));

            if (viewModel == null)
            {
                throw new InvalidOperationException($"Failed to resolve {typeof(TViewModel).Name}");
            }

            // ViewModel に IParameterReceiver が実装されていればパラメーターを渡す
            if (viewModel is IParameterReceiver parameterReceiver)
            {
                parameterReceiver.ReceiveParameter(parameter);
            }

            window.DataContext = viewModel;

            if (owner != null)
            {
                window.Owner = owner;
            }

            // 画面を閉じた際の処理
            window.Closed += (sender, args) =>
            {
                // ViewModelにIResultProvider<TResult>が実装されていた場合(型パターンマッチング)
                if (viewModel is IResultProvider<TResult> resultProvider)
                {
                    resultCallback?.Invoke(resultProvider.GetResult());
                }
                else
                {
                    // TResult型のデフォルト値が resultCallback に渡される
                    // TResult型が参照型(クラスなど)だった場合は null になる
                    // TResult型がプリミティブな値だったらその方のデフォルト値になる(intなら0)
                    resultCallback?.Invoke(default);
                }
            };

            window.ShowDialog();
        }
        catch (Exception)
        {
            // ログなどを記録

            throw;
        }
    }

    public void CloseWindow(Window window)
    {
        window.Close();
    }
}

ShowWindowWithCallbackメソッドで前回と異なる実装は、画面が閉じられたときに発火するイベントwindow.Closedにラムダ式でメソッドを追加している箇所です。

型パターンマッチングでジェネリックの型パラメータのTViewModelIResultProvider<TResult>が実装されていれば、コールバックメソッド(Action デリゲート)を実行します。

Action デリゲートの実行方法は下記のような書き方がありますが、今回は null チェックを加えた書き方にしています。

// nullチェックを含む実行方法
resultCallback?.Invoke(resultProvider.GetResult());

// nullでないことが確実な場合
resultCallback(resultProvider.GetResult());

resultProvider.GetResult()はまだ実装していないのでここではコンパイルエラーが発生しますが次へ進みます。

サブ画面の結果を受けるインターフェースを作成

次に、サブ画面の結果を受け取るためのインターフェースを作成します。

"Services\IResultProvider.cs"
    /// <summary>
    /// 画面の結果を提供するためのインターフェース
    /// </summary>
    public interface IResultProvider<TResult>
    {
        /// <summary>
        /// 結果を取得する
        /// </summary>
        /// <returns>画面の結果</returns>
        TResult? GetResult();
    }

このインターフェースをサブ画面の ViewModel に実装します。

前回の記事ではサブ画面はただの文字列を渡していたので、今回はUserEntityオブジェクトを受け取るようにします。親画面の実装も後ほど修正します。

"ViewModels\SubWindowViewModel.cs"
using CommunityToolkit.Mvvm.ComponentModel;
using MVVM.MultiWindowSample.Entitirs;
using MVVM.MultiWindowSample.Services;

namespace MVVM.MultiWindowSample.ViewModels
{
    public partial class SubWindowViewModel
        : ObservableObject,
          IParameterReceiver,
+         IResultProvider<UserEntity>
    {
        [ObservableProperty]
-       private string _userName;
+       private UserEntity _userInfo;

+       public UserEntity? GetResult()
+       {
+           return UserInfo;
+       }

        public void ReceiveParameter(object parameter)
        {
            if (parameter is UserEntity entity)
            {
-               UserName = entity.Name;
+               UserInfo = entity;
            }
        }
    }
}

GetResultメソッドではキャンセルされた場合など、値が返されないケースに対応するためにUserEntity?と null を許容しています。

画面の修正

サブ画面にデータを渡して親画面の DataGrid に反映させるためのフローは下記のとおりです。

  • DataGrid 内のボタンクリック時に、選択した行のインデックスとデータ(マルチバインディング)をサブ画面に渡す
  • サブ画面でデータを編集後UserEntityをと DataGrid の選択行のインデックス返す(キャンセル時は null を返す)
  • インデックスの情報を元に DataGrid のコレクションのデータを更新する

DataGrid の選択した行のインデックスとUserEntityを取得する必要があるため、Buttonの CommandParameter としてIMultiValueConverterを使用してマルチバインディングに対応したコンバーターを作成します。

"Converters\ArrayMultiValueConverter.cs"
using System.Globalization;
using System.Windows.Data;

namespace MVVM.MultiWindowSample.Converters
{
    /// <summary>
    /// 複数のバインディングソースの値を1つのターゲット値に変換する
    /// </summary>
    public class ArrayMultiValueConverter : IMultiValueConverter
    {
        /// <summary>
        /// 複数の値を配列に変換する
        /// </summary>
        /// <param name="values">変換する値の配列</param>
        /// <param name="targetType">変換先の型</param>
        /// <param name="parameter">コンバーターパラメーター</param>
        /// <param name="culture">カルチャー情報</param>
        /// <returns>変換された値の配列のクローン</returns>
        public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
        {
            // 元のデータが変更されないように値をコピーしてCommandParameterに渡す
            return values.Clone();

        }

        /// <summary>
        /// 今回は使用しない
        /// </summary>
        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
        {
            throw new NotImplementedException();
        }
    }
}

values.Clone()は元の配列への参照を渡さないようにするための実装です。

作成したコンバーターを XAML に実装します。

"Views\MainWindow.xaml"
<Window x:Class="MVVM.MultiWindowSample.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:Icon="http://metro.mahapps.com/winfx/xaml/iconpacks"
        xmlns:bh="http://schemas.microsoft.com/xaml/behaviors"
        xmlns:cv="clr-namespace:MVVM.MultiWindowSample.Converters"
        Width="1080"
        Height="720"
        AllowsTransparency="True"
        Background="Transparent"
        WindowStartupLocation="CenterScreen"
        WindowStyle="None">
    <bh:Interaction.Triggers>
        <bh:EventTrigger EventName="Loaded">
            <bh:InvokeCommandAction Command="{Binding InitializeCommand}" />
        </bh:EventTrigger>
    </bh:Interaction.Triggers>

    <Window.Resources>
        <BooleanToVisibilityConverter x:Key="BoolToVis" />

+       <cv:ArrayMultiValueConverter x:Key="ArrayMultiValueConverter" />
    </Window.Resources>

    <!-- 省略 -->

        <DataGrid Grid.Row="1"
                    CellStyle="{DynamicResource DataGridCellStyle}"
                    ColumnHeaderStyle="{DynamicResource DataGridColumnHeaderStyle}"
                    ItemsSource="{Binding FilteredCustomerList}"
                    RowStyle="{DynamicResource DataGridRowStyle}"
                    ScrollViewer.HorizontalScrollBarVisibility="Auto"
                    Style="{DynamicResource DataGridStyle}">
            <DataGrid.Columns>

                <DataGridTemplateColumn Width="Auto"
                                        Header="編集"
                                        IsReadOnly="True">
                    <DataGridTemplateColumn.CellTemplate>
                        <DataTemplate>
                            <StackPanel Orientation="Horizontal">
                                <!-- 編集ボタン -->
+                               <Button Command="{Binding DataContext.RowEditCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}" Style="{StaticResource gridEditButton}">
+                                   <Icon:PackIconMaterial Kind="PencilOutline" Style="{StaticResource gridButtonIcon}" />
+                                   <Button.CommandParameter>
+                                       <MultiBinding Converter="{StaticResource ArrayMultiValueConverter}">
+                                           <Binding Path="SelectedItem" RelativeSource="{RelativeSource AncestorType=DataGrid}" />
+                                           <Binding Path="SelectedIndex" RelativeSource="{RelativeSource AncestorType=DataGrid}" />
+                                       </MultiBinding>
+                                   </Button.CommandParameter>
+                               </Button>

                                <!-- 削除ボタンボタン -->
+                               <Button Margin="5,0,0,0"
+                                       Command="{Binding DataContext.RowDeleteCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
+                                       Style="{StaticResource gridRemoveButton}">
+                                   <Icon:PackIconMaterial Kind="DeleteOutline" Style="{StaticResource gridButtonIcon}" />
+                                   <Button.CommandParameter>
+                                       <MultiBinding Converter="{StaticResource ArrayMultiValueConverter}">
+                                           <Binding Path="SelectedItem" RelativeSource="{RelativeSource AncestorType=DashStyle}" />
+                                           <Binding Path="SelectedIndex" RelativeSource="{RelativeSource AncestorType=DashStyle}" />
+                                       </MultiBinding>
+                                   </Button.CommandParameter>
+                               </Button>
                            </StackPanel>
                        </DataTemplate>
                    </DataGridTemplateColumn.CellTemplate>
                </DataGridTemplateColumn>

            <!-- 省略 -->

            </DataGrid.Columns>
        </DataGrid>

    <!-- 省略 -->

</Window>

Button要素にリソースディクショナリを使用してスタイルをカスタマイズしていますが実装の解説は割愛します。詳細は GitHub リポジトリを参照してください。

次にMainWindowViewModelにコマンドを追加します。

"ViewModels\MainWindowViewModel.cs"
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MVVM.MultiWindowSample.Entitirs;
using MVVM.MultiWindowSample.Repositories;
using MVVM.MultiWindowSample.Services;
using MVVM.MultiWindowSample.ViewModels;
using MVVM.MultiWindowSample.Views;
using System.Collections.ObjectModel;
using System.Windows;

namespace MVVM.MultiWindowSample
{
    public partial class MainWindowViewModel : ObservableObject
    {
        // 省略

+       [RelayCommand]
+       private void RowEdit(object? sender)
+       {
+           if (sender is object[] values && values.Length == 2)
+           {
+               var selectedIndex = (int)values[1];
+               if (values[0] is UserEntity entity)
+               {
+                   var paramDic = new Dictionary<int, UserEntity>
+                   {
+                       { selectedIndex, entity }
+                   };
+                   var owner = Application.Current.MainWindow;
+                   _windowService.ShowWindowWithCallback<SubWindow, SubWindowViewModel, UserEntity>(
+                       parameter: paramDic,
+                       owner: owner,
+                       resultCallback: CallBackResult);
+                   return;
+               }
+           }
+       }

+       private void CallBackResult(UserEntity? entity)
+       {
+           // 取り敢えず未実装にしておく
+       }

    // 省略

    }
}

今までの実装を確認してみます。

show-subwindow.gif

追加したRowEditメソッドにブレークポイントを当ててみると、選択した行のインデックスとUserEntityが取得できているのがわかります。

multi-binding-converter.png

サブ画面の修正

DataGrid より正しくデータが取得できていることが確認できたので、サブ画面の実装を指定いきます。

"Views\SubWindow.xaml"
<Window x:Class="MVVM.MultiWindowSample.Views.SubWindow"
        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:local="clr-namespace:MVVM.MultiWindowSample.Views"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        Title="SubWindow"
        Width="800"
        Height="800"
        WindowStartupLocation="CenterOwner"
        mc:Ignorable="d">
    <Grid Margin="16">
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <Grid>
            <StackPanel Margin="0,0,0,24" HorizontalAlignment="Center">
                <Label Content="編集画面" FontSize="48" />
            </StackPanel>
        </Grid>

        <Grid Grid.Row="1">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>

            <Grid.ColumnDefinitions>
                <ColumnDefinition Width="200" />
                <ColumnDefinition Width="*" />
            </Grid.ColumnDefinitions>

            <!--#region 名前-->
            <StackPanel Margin="0,0,16,0" HorizontalAlignment="Right">
                <Label Content="名前" FontSize="32" />
            </StackPanel>

            <TextBox Grid.Column="1"
                     Width="350"
                     Margin="0,0,0,24"
                     Padding="8"
                     HorizontalAlignment="Left"
                     FontSize="24"
                     Text="{Binding UserName}" />
            <!--#endregion-->

            <!--#region 年齢-->
            <StackPanel Grid.Row="1"
                        Margin="0,0,16,0"
                        HorizontalAlignment="Right">
                <Label Content="年齢" FontSize="32" />
            </StackPanel>

            <TextBox Grid.Row="1"
                     Grid.Column="1"
                     Width="80"
                     Margin="0,0,0,24"
                     Padding="8"
                     HorizontalAlignment="Left"
                     FontSize="24"
                     Text="{Binding Age}" />
            <!--#endregion-->

            <!--#region 性別-->
            <StackPanel Grid.Row="2"
                        Margin="0,0,16,0"
                        HorizontalAlignment="Right">
                <Label Content="性別" FontSize="32" />
            </StackPanel>

            <ComboBox Grid.Row="2"
                      Grid.Column="1"
                      Width="180"
                      Margin="0,0,0,24"
                      Padding="8"
                      HorizontalAlignment="Left"
                      Cursor="Hand"
                      DisplayMemberPath="DisplayGender"
                      FontSize="24"
                      ItemsSource="{Binding GenderList}"
                      SelectedItem="{Binding SelectedGender}" />
            <!--#endregion-->

            <!--#region Email-->
            <StackPanel Grid.Row="3"
                        Margin="0,0,16,0"
                        HorizontalAlignment="Right">
                <Label Content="Email" FontSize="32" />
            </StackPanel>

            <TextBox Grid.Row="3"
                     Grid.Column="1"
                     Width="350"
                     Margin="0,0,0,24"
                     Padding="8"
                     HorizontalAlignment="Left"
                     FontSize="24"
                     Text="{Binding Email}" />
            <!--#endregion-->

            <!--#region 電話番号-->
            <StackPanel Grid.Row="4"
                        Margin="0,0,16,0"
                        HorizontalAlignment="Right">
                <Label Content="電話番号" FontSize="32" />
            </StackPanel>

            <TextBox Grid.Row="4"
                     Grid.Column="1"
                     Width="350"
                     Margin="0,0,0,24"
                     Padding="8"
                     HorizontalAlignment="Left"
                     FontSize="24"
                     Text="{Binding PhoneNumber}" />
            <!--#endregion-->

            <!--#region 郵便番号-->
            <StackPanel Grid.Row="5"
                        Margin="0,0,16,0"
                        HorizontalAlignment="Right">
                <Label Content="郵便番号" FontSize="32" />
            </StackPanel>

            <TextBox Grid.Row="5"
                     Grid.Column="1"
                     Width="150"
                     Margin="0,0,0,24"
                     Padding="8"
                     HorizontalAlignment="Left"
                     FontSize="24"
                     Text="{Binding ZipCode}" />
            <!--#endregion-->

            <!--#region 住所-->
            <StackPanel Grid.Row="6"
                        Margin="0,0,16,0"
                        HorizontalAlignment="Right">
                <Label Content="住所" FontSize="32" />
            </StackPanel>

            <TextBox Grid.Row="6"
                     Grid.Column="1"
                     Width="500"
                     Margin="0,0,0,24"
                     Padding="8"
                     HorizontalAlignment="Left"
                     FontSize="24"
                     Text="{Binding Address}" />
            <!--#endregion-->

            <!--#region 会社名-->
            <StackPanel Grid.Row="7"
                        Margin="0,0,16,0"
                        HorizontalAlignment="Right">
                <Label Content="会社名" FontSize="32" />
            </StackPanel>

            <TextBox Grid.Row="7"
                     Grid.Column="1"
                     Width="500"
                     Margin="0,0,0,24"
                     Padding="8"
                     HorizontalAlignment="Left"
                     FontSize="24"
                     Text="{Binding CompanyName}" />
            <!--#endregion-->

            <StackPanel Grid.Row="8"
                        Grid.Column="1"
                        Margin="0,0,24,0"
                        HorizontalAlignment="Right"
                        Orientation="Horizontal">
                <Button Width="100"
                        Command="{Binding SaveCommand}"
                        CommandParameter="{Binding RelativeSource={RelativeSource AncestorType=Window}}"
                        Content="保存"
                        Cursor="Hand"
                        FontSize="24" />
            </StackPanel>
        </Grid>
    </Grid>
</Window>

Label と TextBox を使用したオーソドックスなレイアウトです。MVVM パターンで ComboBox の使用方法が詳しく知りたい方は以下の記事を参考にして下さい。

次にSubWindowViewModel.csを修正します。

"ViewModels\SubWindowViewModel.cs"
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MVVM.MultiWindowSample.Entitirs;
using MVVM.MultiWindowSample.Services;
using System.Windows;

namespace MVVM.MultiWindowSample.ViewModels
{
    public partial class SubWindowViewModel : ObservableObject, IParameterReceiver, IResultProvider<Dictionary<int, UserEntity>>
    {
        // 保存ボタンが押されたかどうかを示すフラグ
        private bool _saveButtonPressed = false;

        private readonly IWindowService _windowService;

        private UserEntity _userInfo;

        [ObservableProperty]
        private string _userName;

        [ObservableProperty]
        private string _age;

        [ObservableProperty]
        private string _email;

        [ObservableProperty]
        private string _phoneNumber;

        [ObservableProperty]
        private string _zipCode;

        [ObservableProperty]
        private string _address;

        [ObservableProperty]
        private string _companyName;

        [ObservableProperty]
        private List<GenderComboBox> _genderList;

        [ObservableProperty]
        private GenderComboBox _selectedGender;
        private int _key;

        partial void OnSelectedGenderChanged(GenderComboBox value)
        {
            if (value == null) return;

            if (value is GenderComboBox genderComboBox)
            {
                SelectedGender = genderComboBox;
            }
        }

        [RelayCommand]
        private void Save(object? sender)
        {
            if (sender is Window window)
            {
                _saveButtonPressed = true; // フラグを設定
                _windowService.CloseWindow(window);
            }
        }

        public SubWindowViewModel(IWindowService windowService)
        {
            GenderList = new List<GenderComboBox>
            {
                new GenderComboBox(1),
                new GenderComboBox(2),
                new GenderComboBox(3),
            };

            // 先頭を初期値として表示させる
            SelectedGender = GenderList[0];

            _windowService = windowService;
        }

        public Dictionary<int, UserEntity>? GetResult()
        {
            // 保存ボタンが押された場合のみデータを返す
            if (!_saveButtonPressed) return null;

            var newEntity = _userInfo.GenNewEntity(
                UserName,
                Age,
                SelectedGender,
                Email,
                PhoneNumber,
                ZipCode,
                Address,
                CompanyName);

            return new Dictionary<int, UserEntity>
            {
                {_key, newEntity }
            };
        }

        public void ReceiveParameter(object parameter)
        {
            if (parameter is Dictionary<int, UserEntity> dic)
            {
                var keys = dic.Keys;

                _key = keys.FirstOrDefault();

                var entity = dic[_key];

                if (entity == null) throw new ArgumentNullException("不正な値です");

                InitializedPropertySet(entity);
            }
        }

        private void InitializedPropertySet(UserEntity entity)
        {
            // キャッシュ用
            _userInfo = entity;

            UserName = entity.Name;
            Age = entity.Age.ToString();
            Email = entity.Email;
            PhoneNumber = entity.PhoneNumber;
            ZipCode = entity.PostalCode.Value;
            Address = entity.Address;
            CompanyName = entity.CompanyName;

            var genderId = entity.Gender switch
            {
                "男" => 1,
                "女" => 2,
                _ => 3
            };

            SelectedGender = GenderList.Where(e => e.Id == genderId).FirstOrDefault()
                ?? throw new NullReferenceException("nullの値を参照しています");
        }
    }
}

一気に記述しましたが、重要なところだけ解説していきます。

ReceiveParameterメソッドではUserEntityを受け取るようにしていましたが、渡ってくるデータ型がDictionary<int, UserEntity>に変わったので変更しています。

GetResultメソッドはタイトルバー右上の閉じるボタンでも編集中のデータが返されてしまう実装になってしまっていたので、保存ボタンを押したときのみDictionary<int, UserEntity>を返し、それ以外だとnullを返すように修正しました。

その他、詳細は割愛しますがUserEntityGenNewEntityメソッドを追加しました。これは、編集した TextBox のデータを渡して新しいUserEntityインスタンスを返しています。

アプリケーションを起動して、これまでの実装を確認します。

show-subwindow-edit.gif

編集画面の情報が反映されていることが分かります。

次にタイトルバー右上のバツボタンを押したときの動作も見てみます。

show-subwindow-edit-cancel.gif

編集した情報が反映されていないことが確認できました。

おわりに

Prism などのライブラリを使用せず、MVVM パターンでサブウィンドウを表示する方法について見てきました。

一つの例として参考になればと思います。

また、今回の実装では SQLite に保存するなどの処理は実装していないので、実際はそこまで実装する必要があるかと思います。

今回作成したアプリのコードはGitHub で公開しています。

おすすめの書籍

独習C# 第5版 [ 山田 祥寛 ]

独習C# 第5版 [ 山田 祥寛 ]

Amazonで見る楽天市場で見るYahoo!ショッピングで見る
[改訂新版]実戦で役立つ C#プログラミングのイディオム/定石&パターン [ 出井 秀行 ]

[改訂新版]実戦で役立つ C#プログラミングのイディオム/定石&パターン [ 出井 秀行 ]

Amazonで見る楽天市場で見るYahoo!ショッピングで見る

以下の書籍は初心者向けではないですが、コードの書き方で迷いがあるときに読んでみると良いかもしれません。言語は C# ですが、他の言語でも役立つ考え方や学びがあると思います。

.NETのクラスライブラリ設計 改訂新版 開発チーム直伝の設計原則、コーディング標準、パターン【電子書籍】[ Krzysztof Cwalina ]

.NETのクラスライブラリ設計 改訂新版 開発チーム直伝の設計原則、コーディング標準、パターン【電子書籍】[ Krzysztof Cwalina ]

Amazonで見る楽天市場で見るYahoo!ショッピングで見る