Elm Architectureを利用したMVVMでのUserControl --- C# WPF

下記記事にて、C#でElm Architectureを利用してWPFアプリケーションを作成しました。
Elm Architectureを利用したMVVM --- C# WPF - 何でもプログラミング

今回はElm ArchitectureでUserControlを実装してみたいと思います。

F#版での実装は下記記事を参照してください。
F#でWPF --- Elm Architectureで実装されたUserControl - 何でもプログラミング

アプリケーションコード

Counterコントロールを配置しただけのViewと、値を保存・変更するだけのModel・Updaterになります。

引き続き、このCounterコントロールを作成していきます。

f:id:any-programming:20170531163613p:plain
Xaml

<Window x:Class="WpfApp.MainWindow"       
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"       
        xmlns:local="clr-namespace:WpfApp" 
        Title="MainWindow" Height="100" Width="200">
    <Grid>
        <local:Counter Value="{Binding Value}" ValueChanged="{Binding SetValue}"  Margin="10" Height="23" />
    </Grid>
</Window>

C#

class Model
{
    public int Value { get; }
    public Model(int value) =>
        Value = value;
}
class Updater
{
    public Model SetValue(Model model, int value) =>
        new Model(value);
}
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        DataContext = new DataContext(new Model(0), new Updater());
    }
}


Counterコントロール

Increment及びDecrementが押された場合にMain側に通知し(ValueChanged)、Main側の値(Value)が変わった場合にカウンタの値を更新するように実装してあります。

C#で利用しているDependencyPropertyManagerは下記記事を参照してください。
DependencyProperty定義の記述量削減 - 何でもプログラミング

また、RegisterWithUpdateName、DataContextクラスは後程実装いたします。

f:id:any-programming:20170531164948p:plain
Xaml

<UserControl x:Class="WpfApp.Counter"
             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" 
             mc:Ignorable="d" 
             d:DesignHeight="23" d:DesignWidth="200">
    <Grid Height="23">
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="23"/>
            <ColumnDefinition Width="*"/>
            <ColumnDefinition Width="23"/>
        </Grid.ColumnDefinitions>
        <Button Command="{Binding Decrement}" Content="◀" Grid.Column="0" />
        <TextBox Text="{Binding Count}" Grid.Column="1" IsReadOnly="True" TextAlignment="Center" VerticalContentAlignment="Center" />
        <Button Command="{Binding Increment}" Content="▶" Grid.Column="2" />
    </Grid>
</UserControl>

C#

public partial class Counter : UserControl
{
    static DependencyPropertyManager<Counter> _dp = new DependencyPropertyManager<Counter>();

    // 値が変わった場合にUpdaterのSetCountを呼ぶように登録
    public static readonly DependencyProperty ValueProperty = _dp.RegisterWithUpdateName<int>(nameof(Updater.SetCount));
    public int Value { get => (int)_dp.Get(this); set => _dp.Set(this, value); }

    public static readonly DependencyProperty ValueChangedProperty = _dp.Register<ICommand>();
    public ICommand ValueChanged { get => (ICommand)_dp.Get(this); set => _dp.Set(this, value); }

    class Model
    {
        public int Count { get; }
        public Model(int count) =>
            Count = count;
    }

    class Updater
    {
        Counter _uc;
        public Updater(Counter uc) =>
            _uc = uc;
        public PostCommand Increment(Model model) =>
            new PostCommand(_uc.ValueChanged, model.Count + 1);
        public PostCommand Decrement(Model model) =>
            new PostCommand(_uc.ValueChanged, model.Count - 1);
        public Model SetCount(Model model, int count) =>
            new Model(count);
    }

    public Counter()
    {
        InitializeComponent();
 
        // UserControlのDataContextはMain側で利用されるので、Content(今回はGrid)のDataContextにセット 
        ((FrameworkElement)Content).DataContext = new DataContext(new Model(0), new Updater(this));
    }
}


RegisterWithUpdateName

値変更のコールバック時に、UserControlのContentのDataContextのExecute(後述します。)を呼び出します。

public DependencyProperty RegisterWithUpdateName<TValue>(string updateName, TValue defaultValue = default(TValue), [CallerMemberName]string name = "")
{
    name = name.Substring(0, name.Length - "Property".Length);
    var metadata = new PropertyMetadata(defaultValue, (obj, args) =>
        (((obj as UserControl).Content as FrameworkElement).DataContext as DataContext).Execute(updateName, args.NewValue));
    return DependencyProperty.Register(name, typeof(TValue), typeof(TClass), metadata);
}


DataContext

元々のDataContextではModelを返す関数しか対応していませんでしたが、Model更新後にCommandを実行できるよう、PostCommandクラスを返す関数にも対応させます。

またRegisterWithUpdateNameからCommandを呼び出せるよう、Execute関数も実装します。

public class PostCommand
{
    public Action Execute { get; }
    public object Model { get; }
    public PostCommand(ICommand command, object parameter, object model = null)
    {
        Model = model;
        Execute = () => command?.Execute(parameter);
    }
}
public class DataContext : DynamicObject, INotifyPropertyChanged
{
    object _model;
    Dictionary<string, PropertyInfo> _propertyInfos;
    Dictionary<string, ICommand> _commands;
    public event PropertyChangedEventHandler PropertyChanged;
    public DataContext(object initialModel, object updater)
    {
        _model = initialModel;
        _propertyInfos = initialModel.GetType().GetProperties().ToDictionary(x => x.Name);
        _commands = updater.GetType().GetMethods().ToDictionary(
            method => method.Name,
            method => (ICommand)new Command<object>(parameter =>
            {
                object prevModel = _model;
                object[] parameters = parameter == null
                    ? new[] { prevModel }
                    : new[] { prevModel, parameter };
                object result = method.Invoke(updater, parameters);
                if (result.GetType() == _model.GetType())
                    _model = result;

                // PostCommandのModelをセット(nullの場合は変更なし)
                else if (result is PostCommand)
                    _model = ((PostCommand)result).Model ?? _model;

                if (prevModel != _model)
                    foreach (var property in _propertyInfos.Values)
                        if (property.GetValue(prevModel) != property.GetValue(_model))
                            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(property.Name));

                // PostCommandの場合、Command実行
                (result as PostCommand)?.Execute();
            }));
    }
    public override bool TryGetMember(GetMemberBinder binder, out object result)
    {
        result = _propertyInfos.ContainsKey(binder.Name)
            ? _propertyInfos[binder.Name].GetValue(_model)
            : _commands[binder.Name];
        return true;
    }

    // 外部からコマンドを呼び出す用
    public void Execute(string name, object parameter) =>
        _commands[name].Execute(parameter);
}