🔰

脱初心者!WPFで次のステップへGO!

2024/05/29に公開

こんばんは。
今回は長年趣味で培ってきたWPFプロジェクトにおいて、頻出のテクニックを紹介したいと思います。

背景

WPF大好きっ!!!な私 dhq_boiler にとって、世界へWPFの素晴らしさを布教するのは必然!というわけで、記事を書きました。

対象読者

WPFのきほんの「き」、つまりWPFの主要素であるXAMLやMVVMパターンについて、なんとなく理解できているものの、具体的にどう書いたらいいのかわからないような開発初心者を対象とします。

目的

Windowsプログラミングに興味がある開発者をこちら側(C#, 特にWPF)にいざないます。

本編

本編では実際にWPFプロジェクトを作成し、スクラッチから作ってみます。スクリーンショット多めで参ります。

完成イメージ

ソースコード

ソースコードはこちら。
https://github.com/dhq-boiler/PublishInMedia/tree/main/WPFSkelton/WPFSkelton

要件

  • 要件1 メインウィンドウにボタンを設置し、そのボタンから子ウィンドウを開けるようにする
  • 要件2 子ウィンドウにテキストボックスとOKボタンとキャンセルボタンを設置する
  • 要件3 子ウィンドウのテキストボックスに何か入力したらOKボタンを活性化する
  • 要件4 子ウィンドウのOKボタンを押下したら、子ウィンドウを閉じメッセージボックスで子ウィンドウのテキストボックスで入力した内容を表示する
  • 要件5 子ウィンドウのキャンセルボタンは無条件で活性化する
  • 要件6 子ウィンドウのキャンセルボタンを押下したら、子ウィンドウを閉じる

方針

  1. MVVMパターンを極力使用する
    MVVMパターンによりデザイナーと開発者が手分けして作業できるメリットのほか、関心の分離、テスト容易性、再利用性、拡張性、メンテナンス性、プラットフォーム独立性などのメリットがあります。長くなるので詳しくはここで語るのは控えておきます。
  2. Prismを使用する
    Prism を使用することで、ViewのDataContextに、Viewと同じ名前のViewModelクラスのインスタンスを自動でセットする機能が利用できます。これを使用することで開発生産性が上がります。
  3. Unity(Prism.Unity)を使用する
    Unityとはゲームエンジンのことではなくて、Dependency Injection コンテナ機能を提供するライブラリのことを指します。Prismとも親和性が高く、少ない手順でDIの恩恵を授かる事ができます。
  4. ReactivePropertyを使用する
    データバインディングを行う時にはINotifyPropertyChangedインターフェースを利用することになります。それを実装する手間を省くことができるメリットと、Rx、すなわちリアクティブプログラミングが可能になります。

1. WPFプロジェクトの作成

Visual Studio 2022で新しいプロジェクトを作成します。
プロジェクトテンプレートは「WPFアプリケーション」。
フレームワークは「.NET 8.0(長期的なサポート)」で良いでしょう。

プロジェクトが作成されて、以下のような画面が表示されます。
ここからスタートです。

2. Prismのインストール

NuGetパッケージマネージャーを使う


ソリューションエクスプローラーで、作成したばかりの自分のプロジェクト>依存関係>NuGetパッケージの管理 をクリックします。

そして、検索窓で「Prism」と入力してから以下のライブラリをインストールします。

  • Prism.Wpf
  • Prism.Unity

インストールされると上の図のように「Prism.Wpf」と「Prism.Unity」に緑のチェックが付きます。

プロジェクトをPrismに最適化する

Prismはそれをインストールしただけでは使うことにはなりません。
以下の手順が必要になります。

  1. App.xamlの修正
  2. App.xaml.csの修正

順に追っていきます。

App.xaml の修正

BEFOREでは System.Windows.Application インスタンスが定義されています。
それから、余計なStartupUriプロパティも定義されていますね。

BEFORE

System.Windows.Application から Prism.Unity.PrismApplication に置き換えます。
置き換える際、XAML名前空間を一つ定義する必要があります。

xmlns:prism="http://prismlibrary.com/"

それから、必要のないStartupUriへの値設定を削除しておきます。

あと、忘れがちなのが、Application.Resources 要素を prism:PrismApplication.Resources 要素に置き換える必要もあります。

AFTER

App.xaml.cs の修正

おそらく、今の状態でApp.xaml.csを開くと、class App : PrismApplication あたりに波線が出ていることでしょう。(下図ではResharper利用による赤波線が出ています)

そこでその行にフォーカスを当ててから(クリックしてから)、コードエディタの左隅に電球マークのボタンが出ると思います。(デフォルトなら黄色く点灯している電球マーク、Resharper利用なら赤く点灯する電球マーク)コンテキストメニューが表示され、次のリンク先のような感じで「抽象クラスの実装」メニューが現れます。これをクリックします。

https://learn.microsoft.com/ja-jp/visualstudio/ide/reference/implement-abstract-class?view=vs-2022

Resharper利用の方は上の図の「Implement missing members」(欠けているメンバーを実装する)をクリックしましょう。

あとは、CreateShellメソッド内を以下のように埋めましょう。

protected override Window CreateShell()
{
    var window = Container.Resolve<MainWindow>();
    return window;
}

3. プロジェクトの整理

3つのフォルダの追加

ソリューションエクスプローラーでプロジェクト配下にフォルダを3つ作ります。

  • Models
  • ViewModels
  • Views

MainWindow.xaml, MainWindow.xaml.csをViewsフォルダに移動します。

MainWindow.xaml の修正

MainWindow.xaml を修正します。
Window要素のx:Classを以下のように修正します。

<Window x:Class="WPFSkelton.Views.MainWindow"
        :

「WPFSkelton.」 と 「MainWindow」 の間に 「Views.」 を足しました。

MainWindow.xaml.cs の修正

あと、クラス定義の方も修正しなければなりません。
MainWindow.xaml.cs を修正します。

名前空間定義の部分に追記します。

namespace WPFSkelton.Views
{
    :

WPFSkeltonの後に「.Views」を足しました。

App.xaml.cs の修正

ここで、App.xaml.csの方でMainWindowが参照できないエラーが出ていると思います。

素直に案内にしたがって、「using WPFSkelton.Views;」を追加します。

4. ReactivePropertyのインストール

NuGetパッケージマネージャーを使う


ソリューションエクスプローラーで、作成したばかりの自分のプロジェクト>依存関係>NuGetパッケージの管理 をクリックします。

そして、検索窓で「ReactiveProperty」と入力してから以下のライブラリをインストールします。

  • ReactiveProperty.WPF

インストールされると上の図のように「ReactiveProperty.WPF」に緑のチェックが付きます。

5. MainWindowViewModelの作成

ViewModelsフォルダにMainWindowViewModelクラスを作成します。

MainWindowViewModel.cs の編集

MainWindowViewModelクラスをpublicに

MainWindowViewModelクラスをpublicにします。

MainWindowViewModelにBindableBaseを継承させる

MainWindowViewModelクラスにPrism.Mvvm.BindableBaseクラスを継承させます。

Titleプロパティの定義

ここで、ReactivePropertyを使ってみます。
MainWindowのタイトルを動的に変更させたいので、MainWindowViewModelにTitleプロパティを作ります。型はReactivePropertySlim<string>としてください。この時 Title { get; } = new("デフォルトのタイトル文字列");のように初期化しておくと良いでしょう。

MainWindow.xaml の修正

Window要素のTitleプロパティを以下のように設定します。これはデータバインディングです。

Title="{Binding Title.Value}"

デバッグ実行

ここでデバッグ実行してみると、以下のようになります。

正しくタイトルが設定されていますね。

6. タイトル変更 ~小休憩~

MainWindowViewModel.cs の修正

MainWindowViewModel.Titleプロパティのデフォルト値を変更しておきます。
「WPFSkelton Launcher」とでもしておきましょうか。

デバッグ実行

デバッグ実行してみると、正しくタイトルが反映されていることがわかります。

7. ボタンの設置と子ウィンドウ

Viewsフォルダに新たなユーザーコントロールを作ります。

ソリューションエクスプローラーで、Viewsフォルダを右クリック>追加>ユーザーコントロール(WPF)をクリックします。

ユーザーコントロールのファイル名は「InputStringDialog.xaml」とでもしておきましょう。

InputStringDialog.xaml の編集

UserControl要素の値を書きます。

<UserControl
    x:Class="WPFSkelton.Views.InputStringDialog"
    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:WPFSkelton.Views"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">
    <DockPanel>
        <StackPanel
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Orientation="Vertical">
            <Label Content="文字列を入力してください" />
            <TextBox Margin="5" />
            <StackPanel Margin="5" Orientation="Horizontal">
                <StackPanel.Resources>
                    <Style TargetType="Button">
                        <Setter Property="Width" Value="75" />
                        <Setter Property="Height" Value="25" />
                    </Style>
                </StackPanel.Resources>
                <Button Content="OK" />
                <Button Content="キャンセル" />
            </StackPanel>
        </StackPanel>
    </DockPanel>
</UserControl>

ここで、「要件2 子ウィンドウにテキストボックスとOKボタンとキャンセルボタンを設置する」が成立します。

InputStringDialogViewModel.cs の作成

ViewModels フォルダ内に InputStringDialogViewModel.cs を作成します。

InputStringDialogViewModel を public かつ継承、インターフェース実装

作成したら、InputStringDialogViewModelクラスをpublicにします。
それから、BindableBaseクラスを継承し、IDialogAwareインターフェースを実装します。
先ほどのように赤波線が表示されるので、電球マークから「インターフェースの実装」を行ってください。

メンバーの実装をいくらか単純化しておきました。(CanCloseDialogメソッドに式本体を適用するなど)

2つのプロパティ定義

ここで、InputStringDialogViewModelクラスに2つのReactiveCommand型プロパティを定義します。一つはOKCommand、もう一つはCancelCommandです。
初期化はプロパティ定義行では行わないでおきます。後で、初期化がコンストラクタ内で実行される必要が生じるためです。

public ReactiveCommand OKCommand { get; }
public ReactiveCommand CancelCommand { get; }

InputStringDialogViewModel のコンストラクタ定義

そして、コンストラクタを定義します。

SubscribeメソッドはOKCommandがトリガーされた際に実行されるラムダ式を指定します。
ここでは、OKCommandの場合、RequestClose?.Invoke(new DialogResult(ButtonResult.OK)) 、CancelCommandの場合、RequestClose?.Invoke(new DialogResult(ButtonResult.Cancel)) としています。動作としては「ダイアログを閉じ、ダイアログの結果(OKかキャンセルされたか)」を子ウィンドウ呼び出し元に伝えます。

public InputStringDialogViewModel()
{
OKCommand = new ReactiveCommand();
OKCommand.Subscribe(_ => RequestClose?.Invoke(new DialogResult(ButtonResult.OK)));

CancelCommand = new ReactiveCommand();
CancelCommand.Subscribe(_ => RequestClose?.Invoke(new DialogResult(ButtonResult.Cancel)));
}

ここで、「要件5 子ウィンドウのキャンセルボタンは無条件で活性化する」と「要件6 子ウィンドウのキャンセルボタンを押下したら、子ウィンドウを閉じる」が成立します。

InputStringDialog.xaml を修正

すでに配置しているOKボタンとキャンセルボタンに先ほど定義したOKCommandとCancelCommandを紐づけます。ButtonクラスにCommandクラスを紐づけるには、単にバインディングしてやればいいです。

<UserControl
    x:Class="WPFSkelton.Views.InputStringDialog"
    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:WPFSkelton.Views"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">
    <DockPanel>
        <StackPanel
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Orientation="Vertical">
            <Label Content="文字列を入力してください" />
            <TextBox Margin="5" />
            <StackPanel Margin="5" Orientation="Horizontal">
                <StackPanel.Resources>
                    <Style TargetType="Button">
                        <Setter Property="Width" Value="75" />
                        <Setter Property="Height" Value="25" />
                    </Style>
                </StackPanel.Resources>
                <Button Command="{Binding OKCommand}" Content="OK" />
                <Button Command="{Binding CancelCommand}" Content="キャンセル" />
            </StackPanel>
        </StackPanel>
    </DockPanel>
</UserControl>

App.xaml.cs を修正

InputStringDialog, InputStringDialogViewModelをDIコンテナに登録

App.RegisterTypesメソッドに一行追加します。

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    //DIコンテナに型を登録

    //InputStringDialogを登録
    containerRegistry.RegisterForNavigation<Views.InputStringDialog, ViewModels.InputStringDialogViewModel>();
}

MainWindowViewModel.cs を修正

IDialogService型プロパティを定義

子ウィンドウを開くのに必要なのが、IDialogServiceインスタンスです。
このIDialogService.ShowDialogメソッドで子ウィンドウを開くことができます。
このIDialogServiceインスタンスはUnityのDIコンテナから注入させて取得することが可能です。

依存性注入させるプロパティを作るには、

  • [Unity.Dependency]属性をプロパティにつける
  • public な setアクセサ を定義する
    ことが必要です。
[Dependency]
public IDialogService DialogService { get; set; }

ReactiveCommand型プロパティを定義

続いて、メインウィンドウの中央の「Open child window」ボタンを押した時の処理を実装します。
まず、MainWindowViewModelクラスに ReactiveCommand型 OpenChildWindowCommandプロパティを定義します。

public ReactiveCommand OpenChildWindowCommand { get; }

コンストラクタを定義

ここでは動作確認のため、暫定的に子ウィンドウでOKボタンを押したら、メッセージボックスを出してみましょう。

コンストラクタでは、上記のOpenChildWindowCommandを初期化して、コマンドの処理を書きます。

public MainWindowViewModel()
{
    OpenChildWindowCommand = new ReactiveCommand();
    OpenChildWindowCommand.Subscribe(() =>
    {
        IDialogResult result = default;
        DialogService.ShowDialog(nameof(InputStringDialog), null, ret => result = ret);
        if (result.Result == ButtonResult.OK)
        {
            MessageBox.Show("OK pressed.");
        }
    });
}

ここで、「要件1 メインウィンドウにボタンを設置し、そのボタンから子ウィンドウを開けるようにする」が成立します。

デバッグ実行する

InputStringDialogViewModel.cs を修正する

次は子ウィンドウを開いた後、子ウィンドウのテキストボックスに何か文字列を入力しないと、OKボタンを押せなくなるようにしてみましょう。

子ウィンドウのテキストボックスに割り当てるプロパティを作成する

まず、InputStringDialogViewModelクラスにInputTextプロパティを追加します。これはReactivePropertySlim<string>型にし、デフォルト文字列を設定せずに初期化しておきます。

public ReactivePropertySlim<string> InputText { get; } = new();

コンストラクタを修正する

OKCommand のインスタンスを作成する時に一工夫します。当初はnew ReactiveCommand()としていました。ここでは、InputText.Select(it => !string.IsNullOrEmpty(it)).ToReactiveCommand() とします。
これについて詳しく説明すると、

  1. InputText. ・・・ InputTextプロパティの値の変更を監視します。
  2. Select(it => !string.IsNullOrEmpty(it)) ・・・InputTextの値(string)をラムダ式内でboolに変換します。ここではInputTextの文字列値が空かnullでないならtrue、そうでないならfalseに射影します。
  3. .ToReactiveCommand() ・・・与えられたbool値で有効/無効を切り替えるReactiveCommandを作成します。

ここで、「要件3 子ウィンドウのテキストボックスに何か入力したらOKボタンを活性化する」が成立します。

また、WithSubscribeメソッドのラムダ式で、DialogParametersクラスを作成し、その中にMainWindowViewModel側に渡したいパラメータをパッキングします。そして、それを渡すために、RequestClose?.Invokeメソッドで渡すDialogResultの第二引数にDialogParametersインスタンスを渡します。

public InputStringDialogViewModel()
{
    //InputTextがnullか空でない時のみOKボタンを活性化する
    //InputTextをbool型に射影(Select)すると、ToReactiveCommandメソッドでコマンド化できる
    OKCommand = InputText.Select(it => !string.IsNullOrEmpty(it)).ToReactiveCommand()
        .WithSubscribe(() =>
        {
            var parameters = new DialogParameters()
            {
                { "InputText", InputText.Value },
            };
            RequestClose?.Invoke(new DialogResult(ButtonResult.OK, parameters));
        });

    //無条件でキャンセルボタンを活性化する
    CancelCommand = new ReactiveCommand();
    CancelCommand.Subscribe(_ => RequestClose?.Invoke(new DialogResult(ButtonResult.Cancel)));
}

InputStringDialogViewModel.csのコードの全体を以下に記します。

public class InputStringDialogViewModel : BindableBase, IDialogAware
{
    public ReactiveCommand OKCommand { get; }
    public ReactiveCommand CancelCommand { get; }
    public ReactivePropertySlim<string> InputText { get; } = new();

    public InputStringDialogViewModel()
    {
        //InputTextがnullか空でない時のみOKボタンを活性化する
        //InputTextをbool型に射影(Select)すると、ToReactiveCommandメソッドでコマンド化できる
        OKCommand = InputText.Select(it => !string.IsNullOrEmpty(it)).ToReactiveCommand()
            .WithSubscribe(() =>
            {
                var parameters = new DialogParameters()
                {
                    { "InputText", InputText.Value },
                };
                RequestClose?.Invoke(new DialogResult(ButtonResult.OK, parameters));
            });

        //無条件でキャンセルボタンを活性化する
        CancelCommand = new ReactiveCommand();
        CancelCommand.Subscribe(_ => RequestClose?.Invoke(new DialogResult(ButtonResult.Cancel)));
    }

    public bool CanCloseDialog() => true;

    public void OnDialogClosed()
    {
    }
    
    public void OnDialogOpened(IDialogParameters parameters)
    {
    }
    
    public string Title => "文字列の入力ダイアログ";
    public event Action<IDialogResult>? RequestClose;
}

InputStringDialogViewModel.cs を修正する

ViewModel側に新たなReactivePropertySlim<string>型InputTextプロパティを作ったので、View側でもテキストボックスにバインディングします。

<UserControl
    x:Class="WPFSkelton.Views.InputStringDialog"
    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:WPFSkelton.Views"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d">
    <DockPanel>
        <StackPanel
            HorizontalAlignment="Center"
            VerticalAlignment="Center"
            Orientation="Vertical">
            <Label Content="文字列を入力してください" />
M           <TextBox Margin="5" Text="{Binding InputText.Value, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
            <StackPanel Margin="5" Orientation="Horizontal">
                <StackPanel.Resources>
                    <Style TargetType="Button">
                        <Setter Property="Width" Value="75" />
                        <Setter Property="Height" Value="25" />
                    </Style>
                </StackPanel.Resources>
                <Button Command="{Binding OKCommand}" Content="OK" />
                <Button Command="{Binding CancelCommand}" Content="キャンセル" />
            </StackPanel>
        </StackPanel>
    </DockPanel>
</UserControl>

MainWindowViewModel.cs を修正する

子ウィンドウの呼び出しは既にコードを書いています。最後に、子ウィンドウから結果の受け取り箇所を変更します。既存のコードでは子ウィンドウのResultがOKなら、単にメッセージボックスで「OK pressed.」と表示するのみでした。
今回は子ウィンドウのDialogResultから"InputText"と名付けられたパラメータを受け取り、その内容をメッセージボックスに表示します。

コンストラクタを修正する

ここでは、if (result.Result == ButtonResult.OK) ブロックの中身を変更します。

if (result.Result == ButtonResult.OK)
{
+    var inputText = result.Parameters.GetValue<string>("InputText");
M    MessageBox.Show($"'{inputText}'と入力しました");
}

コンストラクタの全体は以下の通りです。

public MainWindowViewModel()
{
    OpenChildWindowCommand = new ReactiveCommand();
    OpenChildWindowCommand.Subscribe(() =>
    {
        IDialogResult result = default;
        DialogService.ShowDialog(nameof(InputStringDialog), null, ret => result = ret);
        if (result.Result == ButtonResult.OK)
        {
+           var inputText = result.Parameters.GetValue<string>("InputText");
M           MessageBox.Show($"'{inputText}'と入力しました");
        }
    });
}

これにより子ウィンドウのテキストボックスで入力された文字列をMainWindowViewModel側で受け取り、メッセージボックスに表示することができます。

ここで、「要件4 子ウィンドウのOKボタンを押下したら、子ウィンドウを閉じメッセージボックスで子ウィンドウのテキストボックスで入力した内容を表示する」が成立します。

デバッグ実行

まとめ

いかがでしたでしょうか?私はWPFの頻出テクニックをうまくお伝えできたでしょうか?
WPFではMVVMパターンを使って実装する他、MVVMに従わずWinFormsのようにView側に処理をゴリゴリ書くこともできてしまいます。今回のチュートリアルではなるべくMVVMパターンに従ってコードを書きました。
この記事を通じて、Windowsプログラミングに興味を持っていただけたら幸いです。

ラグザイア

Discussion