肥大化しがちなWPFのXAMLとViewModelをユーザーコントロールを使って分割する方法

C# コンピュータ
C#

WPFでシングルウィンドウアプリケーション(一つのウィンドウだけで完結するアプリ)を作っていると、Viewにコントロールを沢山配置することになり、Viewを構成するXAMLファイルと、データソースとして紐づくViewModelのソースファイルが肥大化しがちです。
役割や機能ごとにファイルを分割できれば良いのですが、調べたところViewはユーザーコントロールで分割出来るようなので試してみました。

プロジェクトの作成

ソースコード

ファイル名:MainWindow.xaml

<Window x:Class="SubViewModel01.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:SubViewModel01"
    mc:Ignorable="d"
    Title="{Binding Title.Value}"
    Height="450"
    Width="800"
    xmlns:i="clr-namespace:Microsoft.Xaml.Behaviors;assembly=Microsoft.Xaml.Behaviors">
  <Window.DataContext>
    <local:MainWindowViewModel />
  </Window.DataContext>
  <i:Interaction.Behaviors>
    <local:ViewModelCleanupBehavior />
  </i:Interaction.Behaviors>
  <Grid>
    <local:SubWindow DataContext="{Binding SubViewModel}" Margin="10"/>
  </Grid>
</Window>

以下の部分でMainWindowViewModel内のSubViewModelプロパティ(SubViewModelのインスタンス)をユーザーコントロールSubWindowのDataContextにデータソースとしてバインドしています。

<local:SubWindow DataContext="{Binding SubViewModel}" Margin="10"/>

ファイル:MainWindowViewModel.cs

using System.Diagnostics;
using System.ComponentModel;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.Reactive.Disposables;
using System.Windows;

namespace SubViewModel01;
public class MainWindowViewModel : INotifyPropertyChanged, IDisposable
{
#region
    // INotifyPropertyChanged
    public event PropertyChangedEventHandler? PropertyChanged;
    protected virtual void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    // IDisposable
    private CompositeDisposable Disposable { get; } = [];
    public void Dispose() => Disposable.Dispose();
#endregion
    /**************************************************************************
    * プロパティ
    **************************************************************************/
    public ReactiveProperty<string> Title { get; private set; }

    public SubViewModel SubViewModel { get; set; }
    public MainWindowViewModel()
    {
        SubViewModel = new SubViewModel()
            .AddTo(this.Disposable);

        Title = new ReactiveProperty<string>("タイトル")
            .AddTo(this.Disposable);

        // MainViewModelはSubViewModelを知っている
        SubViewModel.PropertyChanged += (sender, e) =>
        {
            if (nameof(SubViewModel.Name) == e.PropertyName)
            {
                MessageBox.Show($"{SubViewModel.Name.Value}", "SubViewModelのName変更受信");
            }
            if (nameof(SubViewModel.Price) == e.PropertyName)
            {
                MessageBox.Show($"{SubViewModel.Price.Value}", "SubViewModelのPrice変更受信");
            }
        };

    }
}

MainWindowViewModelのプロパティとしてSubViewModelのインスタンスがセットしています。こちらがMainWindow.xamlを経由しユーザーコントロールのSubWindowのデータソースとしてバインドされます。

MainWindowViewModelからSubViewModelが操作可能ですので、SubViewModelのプロパティの変更イベントで実行される処理をMainWindowViewModelで定義しています。

ファイル名:SubViewModel.cs

using System.Diagnostics;
using System.ComponentModel;
using Reactive.Bindings;
using Reactive.Bindings.Extensions;
using System.Reactive.Disposables;

namespace SubViewModel01;
public class SubViewModel : INotifyPropertyChanged, IDisposable
{
#region
    // INotifyPropertyChanged
    public event PropertyChangedEventHandler? PropertyChanged;
    protected virtual void OnPropertyChanged(string name) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
    // IDisposable
    private CompositeDisposable Disposable { get; } = [];
    public void Dispose() => Disposable.Dispose();
#endregion
    /**************************************************************************
    * プロパティ
    **************************************************************************/
    public ReactiveProperty<string> Name { get; set; }
    public ReactiveProperty<int> Price { get; set; }

    public SubViewModel()
    {
        Name = new ReactiveProperty<string>("★")
            .AddTo(this.Disposable);
        Name.Subscribe(e => OnPropertyChanged(nameof(Name))); // 変更を送信
        Price = new ReactiveProperty<int>(88)
            .AddTo(this.Disposable);
        Price.Subscribe(e=> OnPropertyChanged(nameof(Price))); // 変更を送信
    }
}

ユーザーコントロールSubWindowのViewModelに成ります。
SubViewModelはMainWindowViewModelを知らない為、他のクラスでも使いまわし(再利用)しやすい形になっています。
MainWindowViewModelを直接操作することは出来ませんが、SubViewModel内のプロパティの変更をMainWindowViewModelに通知したいので、OnPropertyChanged()を使い変更を送信しています。送信した通知を拾うかどうかはMainWindowViewModel側で決めることが出来ます。(イベントを拾わなくともSubViewModelは機能する)

ファイル名:SubWindow.xaml

<UserControl x:Class="SubViewModel01.SubWindow"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:local="clr-namespace:SubViewModel01"
             mc:Ignorable="d"
             d:DesignHeight="100" d:DesignWidth="200">
    <StackPanel>
        <TextBox Text="{Binding Name.Value}" FontWeight="Bold"/>
        <TextBox Text="{Binding Price.Value}" FontWeight="Bold"/>
    </StackPanel>
</UserControl>

ユーザーコントロールSubWindowのXAML(View)になります。
基本的にXAMLですのでMainWindow.xamlと同じような書式ですが、最初のタグがWindowの代わりにUserControlとなっててクラス名x:Classがユーザーコントロールの名前に成ります。
d:DesignHeight="100" d:DesignWidth="200">がどのような影響があるか未確認
StackPanelで縦積みにTextBoxをレイアウトします。TextBoxはNameとPriceのValueとバインドしていますが、こちらはSubWindowViewModelのプロパティを示しています。

ファイル名:SubWindow.xaml.cs

using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;

namespace SubViewModel01;

/// <summary>
/// Interaction logic for SubWindow.xaml
/// </summary>
public partial class SubWindow :  System.Windows.Controls.UserControl
{
    public SubWindow()
    {
        InitializeComponent();
    }
}

SubWindow.xamlのコードビハインドになります。
継承元のクラスがWindowではなくSystem.Windows.Controls.UserControlになっているくらいで、MVVMですので基本的な処理はViewModelに任せます。

ファイル名:ViewModelCleanupBehavior.cs

using Microsoft.Xaml.Behaviors;
using System.Windows;

namespace SubViewModel01;
public class ViewModelCleanupBehavior : Behavior<Window>
{
    protected override void OnAttached()
    {
        base.OnAttached();
        this.AssociatedObject.Closed += this.WindowClosed;
    }

    private void WindowClosed(object? sender, EventArgs e)
    {
        (this.AssociatedObject.DataContext as IDisposable)?.Dispose();
    }

    protected override void OnDetaching()
    {
        base.OnDetaching();
        this.AssociatedObject.Closed -= this.WindowClosed;
    }
}

今回のサンプルコードの内容とは関連性はないです。
Windowを終了時紐づいているデータソース(ViewModel)のオブジェクトのDispose()を呼び出しています。

実行

dotnet run

起動直後

ユーザーコントロールSubWindowで定義したテキストボックスが表示されていることを確認

値を変更してみる。

変更された旨のメッセージボックスが表示されるが、こちらはMainWindowViewModel内のコードが実行されています。

感想

サンプルとして用意したユーザーコントロールはシンプルなテキストボックスですが、個人的にはTreeViewやListViewなど比較的複雑なコントロールを別ファイルに切り出して使いたいと考えています。

クラス同士の関係が少し複雑ですので、いつかクラス図を書いて説明出来るようになりたい。

コメント