Functional Reactive Programming + MVVMでのUserControl --- C# WPF

下記記事にてFRPライブラリのSodiumとWPFを連携させてみました。
Functional Reactive Programming + MVVM --- C# WPF - 何でもプログラミング

今回はUserControlでも利用できるよう実装してみたいと思います。

アプリケーションコード

いつも通り、カウンタアプリケーションを実装します。
f:id:any-programming:20170531163613p:plain

Main側は純粋にValueの保存と更新を行うのみの実装になっています。

引き続きCounterコントロールを実装していきます。

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 ViewModel
{
    public DiscreteCell<int> Value { get; }
    public ViewModel(Stream<int> setValue) =>
        Value = setValue.Hold(0);
}
public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
        DataContext = new DataContext<ViewModel>();
    }
}


Counterコントロール

通常のロジックに対し、DependencyPropertyからのDiscreteCellの入力への追加、ICommandへのイベント通知の追加を行います。

それに伴う、DataContextクラスの拡張、DependencyPropertyクラスの作成、Listen拡張メソッドの作成は、後述いたします。

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#

class CounterVM
{
    List<IListener> listeners = new List<IListener>();
    public DiscreteCell<int> Count { get; }
    public CounterVM(UserControl1 uc, Stream<Unit> increment, Stream<Unit> decrement)
    {
        Count = _value.Cell(uc);

        increment.MapTo(1).OrElse(decrement.MapTo(-1)).Snapshot(Count, (dx, x) => x + dx)
            .Listen(listeners, () => uc.ValueChanged);
    }
}
public partial class Counter: UserControl
{
    static DependencyProperty<Counter, int> _value = new DependencyProperty<Counter, int>();
    public static readonly DependencyProperty ValueProperty = _value.Register();
    public int Value { get => _value.Get(this); set => _value.Set(this, value); }

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

    public Counter()
    {
        InitializeComponent();
        ((FrameworkElement)Content).DataContext = new DataContext<CounterVM>(this);
    }
}


DependencyProperty

通常のRegister、Get、Setのほかに、DescreteCellが取得できるようになっています。

DescreteCellがきちんと解放されるよう、インスタンスはConditionalWeakTableで管理しています。

今回はViewModelからの通知を、DescreteCellSinkにPostSendしています。(ViewModel側もSodiumを利用している場合、Listenの中でSendを呼ぶ可能性があり、その場合例外で止まるので、それを避けるため。)

もしFRPの回路を途切らせたくない場合は、型をDescreteCellにしてSwitchするなど、工夫が必要になります。

public class DependencyProperty<TClass, TValue> where TClass : DependencyObject
{
    DependencyProperty _property;
    ConditionalWeakTable<TClass, DiscreteCellSink<TValue>> _sinks = new ConditionalWeakTable<TClass, DiscreteCellSink<TValue>>();
    public DependencyProperty Register(TValue defaultValue = default(TValue), [CallerMemberName]string name = "")
    {
        name = name.Substring(0, name.Length - "Property".Length);
        var metadata = new PropertyMetadata(defaultValue, (obj, args) => 
        {
            if (_sinks.TryGetValue((TClass)obj, out var sink))
                Transaction.Post(() => sink.Send((TValue)args.NewValue));
        });
        _property = DependencyProperty.Register(name, typeof(TValue), typeof(TClass), metadata);
        return _property;
    }
    public TValue Get(TClass obj) =>
        (TValue)obj.GetValue(_property);
    public void Set(TClass obj, TValue value) =>
        obj.SetValue(_property, value);
    public DiscreteCell<TValue> Cell(TClass obj) =>
        _sinks.GetValue(obj, x => new DiscreteCellSink<TValue>(Get(x)));
}


Listen拡張メソッド

StreamとICommandを結びつける関数です。

ViewModel側がSodiumを利用している可能性があるため、Transaction.PostでExecuteしています。(理由はDependencyPropertyクラスの時と同じです。)

FRP回路を途切らせたくない場合はICommandではなく、型をDiscreteCellにしてViewModel側でSwitchするなどの工夫が必要になります。

public static void Listen<T>(this Stream<T> source, List<IListener> listeners, Func<ICommand> getCommand) =>
    listeners.Add(source.Listen(x => Transaction.Post(() => getCommand()?.Execute(x))));


DataContext

引数に好きな値を設定できるように修正しています。

public DataContext(params object[] preArgs)
{
    var constructor = typeof(T).GetConstructors().Single();
    var args = preArgs.Concat(constructor.GetParameters().Skip(preArgs.Length).Select(parameter =>
    ...