JavaScript(ES6)でElm Architecture(Virtual-DOMなし) --- 可変個コントロール

下記記事にてJavaScriptにElmのModelとUpdateの機構を取り入れてみました。
JavaScript(ES6)でElm Architecture(Virtual-DOMなし) - 何でもプログラミング

今回は引き続き、可変個のコントロールに対応してみたいと思います。

アプリケーションコード

カウンターを追加、削除できるアプリケーションを実装してみたいと思います。

f:id:any-programming:20170723141650p:plain

可変個のコントロールの定義として、htmlのtemplate要素を利用しています。

また可変個コントロールの動作の実装にbindItemsを利用しています。(実装はのちほど)

その他CellクラスやstartApp関数は下記記事を参照してください。
JavaScript(ES6)でElm Architecture(Virtual-DOMなし) - 何でもプログラミング

<button id="add">add</button>
<button id="remove">remove</button>

<!-- 可変個カウンタ -->
<div id="counts">
    <template>
        <div>                 
            <button class="dec">dec</button>
            <input type="text" class="count"></input>
            <button class="inc">inc</button>
        </div> 
    </template>
</div>
const initialModel = {
    counts : []
};

const message = {
    addCounter : Symbol(),
    removeCounter : Symbol(),
    increment : Symbol(),
    decrement : Symbol()
}

function update(model, msg){
    switch (msg.id) {
        case message.addCounter :
            return copy(model, {counts : model.counts.insertLast(0)});

        case message.removeCounter :
            return copy(model, {counts : model.counts.removeLast()});
        
        case message.increment : 
            return copy(model, {counts : model.counts.updateAt(msg.index, x => x + 1)});

        case message.decrement :
            return copy(model, {counts : model.counts.updateAt(msg.index, x => x - 1)});

        default : throw "invalid message"
    }
}

function bind(model, send) {
    byId("add").onclick = () => send({ id: message.addCounter });
    byId("remove").onclick = () => send({ id: message.removeCounter });

    // 可変個カウンタBinding
    bindItems(model.map(x => x.counts), "counts", (item, index, element) =>
    {
        const byClass = x => element.getElementsByClassName(x)[0];

        item.listen(x => byClass("count").value = x);
        byClass("dec").onclick = () => send({ id: message.decrement, index: index });
        byClass("inc").onclick = () => send({ id: message.increment, index: index });
    });
}

window.onload = () =>
    startApp(initialModel, update, bind);


bindItems

itemsのCell、親要素のID、コントロールのbindingを受け取る関数になります。

要素内のtemplateを取得し、実際にコントロールを配置するdiv要素を追加しています。

itemsが変更されたときに、変更の送出 or コントロールの作成 or コントロールの削除を行っています。

function bindItems(itemsCell, parentId, bind) {
    const parent = byId(parentId);
    const itemTemplate = parent.getElementsByTagName("template")[0];
    const panel = document.createElement("div");
    parent.appendChild(panel);
    const cells = [];
    itemsCell.listen(items =>
    {
        for (let i = 0; i < items.length; i++)
        {
            // コントロールが存在
            if (i < cells.length)
            {
                cells[i].send(items[i]);
            }
            // コントロール不足
            else
            {
                const cell = new Cell(items[i]);
                const element = document.importNode(itemTemplate.content, true);
                cells.push(cell);
                panel.appendChild(element);
                bind(cell, i, panel.lastElementChild);
            }
        }
        // コントロール過剰
        for (let i = 0; i < cells.length - items.length; i++)
        {
            cells.pop();
            panel.lastElementChild.remove();
        }
    });
}


外側のスコープのCellをbind内で利用するとリーク

スコープ外のCellを利用すると、コントロールの参照がCell内に残り続けてリークを起こしてしまいます。

そのため、スコープ外のCellを利用する場合は、一旦キャプチャしてbind関数の引数に追加する必要があります。

実装例ですが、C#のものが下記にありますので参照してください。
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール - 何でもプログラミング





JavaScript(ES6)でElm Architecture(Virtual-DOMなし)

Elm自体がJavaScriptを生成するものなので実際には利用することはないと思いますが(さらに生のJavaScriptがよければReact.jsがあります)、ElmのModelとUpdateの仕組みを生のJavaScriptで実装してみました。

WPFC#で実装されたものは、下記記事にて参照できます。
WPFでElm Architecture --- Xaml利用しない版 - 何でもプログラミング

アプリケーションコード

簡単なカウンタアプリを実装します。

f:id:any-programming:20170717201656p:plain

initialModel 状態の初期値を定義
message update関数で処理させる内容の識別子
update 現在のmodelと、messageを受け取って、新しいmodelを返す
bind modelのCell(現在値+変更通知)と、send(message)関数を、html要素にバインドする

CellはFunctional Rective Programming(FRP)でのBehaviorを意図しており、詳しくは下記を参照してください。
SodiumでFunctional Reactive Programming (F#) --- 導入 - 何でもプログラミング

<body>
    <button id="dec">dec</button>
    <input type="text" id="count"></input>
    <button id="inc">inc</button>
</body>
const initialModel = {
    count : 0,
};

const message = {
    increment : Symbol(),
    decrement : Symbol(),
}

function copy(obj, diff) {
    return Object.assign({}, obj, diff);
}

function update(model, msg){
    switch (msg.id) {
        case message.increment :   
            return copy(model, {count : model.count + 1});
        case message.decrement :
            return copy(model, {count : model.count - 1});
        default : throw "invalid message"
    }
}

function byId(name) {
    return document.getElementById(name);
}

function bind(model, send) {
    model.map(x => x.count).listen(x => byId("count").value = x);

    byId("dec").onclick = () => send({ id: message.decrement });
    byId("inc").onclick = () => send({ id: message.increment });
}

window.onload = () =>
    startApp(initialModel, update, bind);


Cellクラス

listenでコールバックを登録し、sendでデータを送出し、mapでデータの変換を行います。

listenした後、コールバックを解除する機能は実装してありませんので、利用する際(コントロールを動的に追加or削除)はリークに注意してください。

FRPのライブラリSodiumにJavaScript版がありますので、そちらのCellを利用することも可能です。

class Cell {
    constructor(initialValue) {
        this.value = initialValue;
        this.callbacks = [];
    }
    listen(f) {
        f(this.value);
        this.callbacks.push(f);
    }
    send(value) {
        if (this.value === value)
            return;
        this.value = value;
        this.callbacks.forEach(f => f(value));
    }
    map(f) {
        const cell = new Cell(f(this.value));
        this.callbacks.push(x => cell.send(f(x)));
        return cell;
    }
}


startApp

initialModelとupdateから、modelのCellとsend関数を作成し、bindを呼び出しています。

function startApp(initialModel, update, bind){
    const cell = new Cell(initialModel);
    const send = msg => cell.send(update(cell.value, msg));
    bind(cell, send);
}






WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール --- データによりコントロールの種類変更

下記記事にて可変個のコントロールを表示できるよう実装しました。
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール - 何でもプログラミング

ただし、すべてのコントロールが同じで個数のみが可変であったため、今回は入力データによってコントロールが分岐するような実装をしてみたいと思います。

アプリケーションコード

ボタンを押すと対応した図形が追加され、図形上で左クリックでサイズが大きくなり、図形上で右クリックをすると削除されるようなアプリケーションを作成してみます。

f:id:any-programming:20170717193643p:plain

itemsとselectorとfactoryが渡せるwith_Childrenを用いてコントロールの切り替えを実現しています。(with_Childrenの実装は後述)

コード内で利用されているLINQオペレータは下記記事を参照してください。
LINQ独自オペレータ メモ - 何でもプログラミング

図形モデル定義

interface IShape
{
    double Size { get; }
}
class Circle : IShape
{
    public double Size { get; }
    public Circle(double size) { Size = size; }
}
class Square : IShape
{
    public double Size { get; }
    public Square(double size) { Size = size; }
}
class Triangle : IShape
{
    public double Size { get; }
    public Triangle(double size) { Size = size; }
}

アプリケーションモデル定義

class Model
{
    public IShape[] Shapes { get; }
    public Model(IShape[] shapes) { Shapes = shapes; }
}

モデル更新メッセージ定義

abstract class Message { }
class AddCircle : Message { }
class AddSquare : Message { }
class AddTriangle : Message { }
class IncrementSize : Message
{
    public int Index { get; }
    public IncrementSize(int index) { Index = index; }
}
class RemoveShape : Message
{
    public int Index { get; }
    public RemoveShape(int index) { Index = index; }
}

モデル更新関数

public static Model Update(Model model, Message message)
{
    switch (message)
    {
        case AddCircle msg:
            return new Model(model.Shapes.InsertLast(new Circle(30)).ToArray());

        case AddSquare msg:
            return new Model(model.Shapes.InsertLast(new Square(30)).ToArray());

        case AddTriangle msg:
            return new Model(model.Shapes.InsertLast(new Triangle(30)).ToArray());

        case IncrementSize msg:
            {
                IShape increment(IShape src) =>
                    src is Circle ? new Circle(src.Size + 10) as IShape :
                    src is Square ? new Square(src.Size + 10) as IShape :
                    new Triangle(src.Size + 10) as IShape;
                return new Model(model.Shapes.UpdateAt(msg.Index, increment).ToArray());
            }

        case RemoveShape msg:
            return new Model(model.Shapes.RemoveAt(msg.Index).ToArray());

        default:
            throw new Exception("invalid message");
    }
}

View作成関数

enum Shape
{
    Circle,
    Square,
    Triangle
}
public static FrameworkElement Create(ICell<Model> model, Action<Message> send)
{
    return new StackPanel()
        .with_Margin(10, 10, 10, 10)
        .with_Children(
            new Button()
            .with_Content("Circle追加")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new AddCircle())),

            new Button()
            .with_Content("Square追加")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new AddSquare())),

            new Button()
            .with_Content("Triangle追加")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new AddTriangle())),

            new StackPanel()
            .with_Children(
                model.Map(x => x.Shapes),

                item =>
                    item is Circle ? Shape.Circle :
                    item is Square ? Shape.Square :
                    Shape.Triangle,
                    
                (id, item, index) =>
                {
                    var element =
                        id == Shape.Circle ? new Ellipse().with_Fill(Brushes.Red) as FrameworkElement:
                        id == Shape.Square ? new Rectangle().with_Fill(Brushes.Green) as FrameworkElement:
                        new Path().with_Data(Geometry.Parse("M0,100 H100 L50,0 Z")).with_Stretch(Stretch.Fill).with_Fill(Brushes.Blue) as FrameworkElement;

                    return element
                        .with_Margin(5, 5, 5, 5)
                        .with_Width(item.Map(x => x.Size))
                        .with_Height(item.Map(x => x.Size))
                        .with_MouseLeftButtonDown(x => send(new IncrementSize(index)))
                        .with_MouseRightButtonDown(x => send(new RemoveShape(index)));
                }
            )
        );
}


with_Children

固定コントロールの場合のwith_Childrenと比べて、入力データからコントロールの識別子を返すselector関数が追加され、factoryの引数にも識別子が渡されるようになっています。

新しいデータのコントロール識別子に変更がなければそのままデータを送出し、識別子が異なる場合はコントロールを作り直しています。

もっと賢い差分アルゴリズムを利用すると、コントロールの生成が最小限に抑えられると思います。

public static TPanel with_Children<TPanel, TItem, TViewID>(
    this TPanel element, 
    ICell<TItem[]> items, 
    Func<TItem, TViewID> selector, 
    Func<TViewID, ICell<TItem>, int, FrameworkElement> factory)
    where TPanel : Panel
{
    List<CellSink<TItem>> sinks = new List<CellSink<TItem>>();
    List<TViewID> viewIDs = new List<TViewID>();
    element.Children.Clear();
    items.Listen(xs =>
    {
        for (int i = 0; i < xs.Length; ++i)
        {
            // コントロールが存在
            if (i < sinks.Count)
            {
                TViewID viewID = selector(xs[i]);

                // ViewID変更なし 
                if (Equals(viewID, viewIDs[i]))
                {
                    sinks[i].Send(xs[i]);
                }
                // ViewIDが変わった
                else
                {
                    sinks[i] = new CellSink<TItem>(xs[i]);
                    viewIDs[i] = viewID;
                    element.Children.RemoveAt(i);
                    element.Children.Insert(i, factory(viewID, sinks[i], i));
                }
            }
            // コントロールが足りない
            else
            {
                sinks.Add(new CellSink<TItem>(xs[i]));
                viewIDs.Add(selector(xs[i]));
                element.Children.Add(factory(viewIDs.Last(), sinks.Last(), i));
            }
        }
        // コントロールが過剰
        for (int i = 0; i < sinks.Count - xs.Length; ++i)
        {
            sinks.RemoveAt(sinks.Count - 1);
            viewIDs.RemoveAt(viewIDs.Count - 1);
            element.Children.RemoveAt(element.Children.Count - 1);
        }
    });
    return element;
}


factoryの外側のスコープのCellを利用する場合

そのままCellを利用するとリークを起こしてしまうので、別途追加のCellを受け取るwith_Childrenが必要となります。

下記記事にても同様の実装がありますので、こちらを参照してください。
WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール - 何でもプログラミング





Throttle(一定時間リクエストがなければ実行をする)の実装

スライダーなどから大量のリクエストが送られてきたときに、全て処理する必要がない場合、一定時間リクエストがなければ最新の値で処理を行うことがあります。

ReactiveExtensionにもThrottleオペレータは用意されていますので、普段はこれを利用するので問題ないと思います。
GitHub - Reactive-Extensions/Rx.NET: The Reactive Extensions for .NET

今回はThrottle処理を行うクラスを実装してみたいと思います。

Throttleクラス

Delayで処理を遅らせたTaskを利用します。

リクエストが来たら、現在のTaskをキャンセルして新しいTaskを走らせる、とても簡単な内容になっています。

public class Throttle
{
    CancellationTokenSource _cancel = new CancellationTokenSource();
    int _milliseconds;
    public Throttle(int milliseconds) =>
        _milliseconds = milliseconds;
    public void Post(Action action)
    {
        // 現在のTaskをキャンセル
        _cancel.Cancel();
        _cancel = new CancellationTokenSource();

        // 指定時間待ったあと処理を行う
        Task.Delay(_milliseconds, _cancel.Token)
            .ContinueWith(task => action(), _cancel.Token);
    }
}


動作確認

スライダーを動かすと、値がテキストに反映されるアプリを作成してみます。

スライダーを動かしてから1秒以内にまたスライダーを動かす限り、テキストが更新されません。

現状Postは別スレッドで実行されるため、InvokeAsyncでUIスレッドでテキストを更新しています。

f:id:any-programming:20170714181130p:plain

var throttle = new Throttle(1000);

slider.ValueChanged += (s, e) =>
    throttle.Post(() =>
        textblock.Dispatcher.InvokeAsync(() => 
            textblock.Text = ((int)e.NewValue).ToString()));






WPFでElm Architecture --- Xaml利用しない版 --- 可変個のコントロール

下記記事にてXamlを利用しないElm Architectureを実装しました。
WPFでElm Architecture --- Xaml利用しない版 - 何でもプログラミング

ただし、固定個のコントロールにしか対応していませんでした。

今回はGridなどのChildrenを動的に変更できるよう実装してみたいと思います。

アプリケーションコード

今回は可変個のカウンタを利用できるアプリケーションを作成してみます。
f:id:any-programming:20170712201450p:plain

動的にコントロールを作成するために、with_ChildrenにitemsのCellとFactoryを渡せるよう実装してあります。

利用されているLINQのオペレータは下記記事を参照してください。
LINQ独自オペレータ メモ - 何でもプログラミング

class Model
{
    public int[] Counts { get; }
    public Model(int[] counts) { Counts = counts; }
}
abstract class Message { }
class AddCounter : Message { }
class RemoveCounter : Message { }
class Increment : Message
{
    public int Index { get; }
    public Increment(int index) { Index = index; }
}
class Decrement : Message
{
    public int Index { get; }
    public Decrement(int index) { Index = index; }
}

class Updater
{
    public static Model Update(Model model, Message message)
    {
        switch (message)
        {
            case AddCounter msg:
                return new Model(model.Counts.InsertAt(model.Counts.Length, 0).ToArray());

            case RemoveCounter msg:
                return new Model(model.Counts.RemoveAt(model.Counts.Length - 1).ToArray());

            case Increment msg:
                return new Model(model.Counts.UpdateAt(msg.Index, x => x + 1).ToArray());

            case Decrement msg:
                return new Model(model.Counts.UpdateAt(msg.Index, x => x - 1).ToArray());

            default:
                throw new Exception("invalid message");
        }
    }
}
public static FrameworkElement Create(ICell<Model> model, Action<Message> send)
{
    return new StackPanel()
        .with_Margin(10, 10, 10, 10)
        .with_Children(                
            new Button()
            .with_Content("カウンタ追加")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new AddCounter())),

            new Button()
            .with_Content("カウンタ削除")
            .with_Margin(5, 5, 5, 5)
            .with_Click(() => send(new RemoveCounter())),

            new StackPanel()
            .with_Children(model.Map(x => x.Counts), (count, index) =>
            {
                return new Grid()
                    .with_Margin(5, 5, 5, 5)
                    .with_ColumnDefinitions(
                        new ColumnDefinition() { Width = new GridLength(50) },
                        new ColumnDefinition() { Width = new GridLength(1, GridUnitType.Star) },
                        new ColumnDefinition() { Width = new GridLength(50) }
                    )
                    .with_Children(
                        new MyButton()
                        .with_GridColumn(0)
                        .with_Content("-")
                        .with_Click(() => send(new Decrement(index))),
 
                        new TextBlock()
                        .with_GridColumn(1)
                        .with_HorizontalAlignment(HorizontalAlignment.Center)
                        .with_VerticalAlignment(VerticalAlignment.Center)
                        .with_Text(count.Map(x => x.ToString())),

                        new Button()
                        .with_GridColumn(2)
                        .with_Content("+")
                        .with_Click(() => send(new Increment(index)))
                    );
            })
        );
}


with_Children

itemsの要素数が変動した時のみコントロールの追加、削除を行うよう実装してあります。

CellSinkとコントロールは1対1で準備されるため、コントロールを削除しても問題なくガーベッジコレクション対象となります。

public static TPanel with_Children<TPanel, TItem>(
    this TPanel element,
    ICell<TItem[]> items,
    Func<ICell<TItem>, int, FrameworkElement> factory)
    where TPanel : Panel
{
    List<CellSink<TItem>> sinks = new List<CellSink<TItem>>();
    element.Children.Clear();
    items.Listen(xs =>
    {
        for (int i = 0; i < xs.Length; ++i)
        {
            // コントロールが存在 : CellSink.Send
            if (i < sinks.Count)
            {
                sinks[i].Send(xs[i]);
            }
            // コントロールが足りない : CellSink、コントロール作成
            else
            {
                sinks.Add(new CellSink<TItem>(xs[i]));
                element.Children.Add(factory(sinks.Last(), i));
            }
        }
        // コントロールが過剰 : CellSink、コントロール削除
        for (int i = 0; i < sinks.Count - xs.Length; ++i)
        {
            sinks.RemoveAt(sinks.Count - 1);
            element.Children.RemoveAt(element.Children.Count - 1);
        }
    });
    return element;
}


factoryの外側のスコープのCellを利用するとリーク発生

今回の例では、factory内でCellは引数のcountしか利用しませんでした。

.with_Text(count.Map(x => x.ToString()))

もし下記のようにスコープ外のCellを利用した場合、コントロールを削除してもCellのコールバック内に参照が残り続けるため、メモリリークが発生してしまいます。

.with_Text(model.Map(x => x.Counts.Length.ToString()))


追加のCellを受け取るwith_Children

関数の引数とfactoryの引数に、新たにCellを1つ追加してあります。

各コントロールごとにCellSinkを用意することにより、本体のCellのほうにコントロールの参照が残らないようにしています。

parameterを2つ、3つ渡したい場合は、同様の実装方法でwith_Children関数を追加してください。

public static TPanel with_Children<TPanel, TItem, TParam>(
    this TPanel element,
    ICell<TItem[]> items,
    ICell<TParam> parameter,
    Func<ICell<TItem>, ICell<TParam>, int, FrameworkElement> factory)
    where TPanel : Panel
{
    List<CellSink<TItem>> sinks = new List<CellSink<TItem>>();

    List<CellSink<TParam>> parameters = new List<CellSink<TParam>>();
    parameter.Listen(x => parameters.ForEach(sink => sink.Send(x)));

    element.Children.Clear();
    items.Listen(xs =>
    {
        for (int i = 0; i < xs.Length; ++i)
        {
            if (i < sinks.Count)
            {
                sinks[i].Send(xs[i]);
            }
            else
            {
                sinks.Add(new CellSink<TItem>(xs[i]));
                parameters.Add(new CellSink<TParam>(parameter.Value));
                element.Children.Add(factory(sinks.Last(), parameters.Last(), i));
            }
        }
        for (int i = 0; i < sinks.Count - xs.Length; ++i)
        {
            sinks.RemoveAt(sinks.Count - 1);
            parameters.RemoveAt(parameters.Count - 1);
            element.Children.RemoveAt(element.Children.Count - 1);
        }
    });
    return element;
}


外側のCellを利用するのを防ぐには

リークのしないwith_Childrenを用意しても、外部のCellを利用する記述を禁止すること自体はできません。

対策例として、factoryのキャプチャ変数の型を確認して、ICellが存在したら例外を出す方法があげられます。

if (factory.Target != null &&
    factory.Target.GetType().GetFields().Any(t =>
        t.FieldType.IsGenericType && t.FieldType.GetGenericTypeDefinition() == typeof(ICell<>)))
    throw new Exception("factory can't capture ICell");

検討はしていませんが、Roslynを利用してコンパイルエラーにするのも可能かも知れません。





WPFでElm Architecture --- Xaml利用しない版

下記記事にて、ViewModelまでをElm Architectureで実装し、Xamlバインディングしてアプリケーションを作成する方法を紹介しました。
Elm Architectureを利用したMVVM --- C# WPF - 何でもプログラミング

今回はXaml部分をC#側で実装したものを作成してみたいと思います。

メリットとしては、型チェックや、IDEC#サポートが利用できるようになり、複雑な挙動を実装しやすくなることです。

デメリットとしては、C#を使える人でないとViewが作れなくなることです。

Virtual-DOMは見送り

Elmでは、Viewの作成でVirtual-DOMを作成すると、フレームワーク側で現在のVirtual-DOMと比較して差分のみを更新してくれます。

この機能を実装するにはかなり大きな作業となってしまいますので、今回はBindingのような機構を利用して実装することにします。

アプリケーションコード

今回も下記のようなカウンタアプリケーションを作成します。
f:id:any-programming:20170712152046p:plain

とりあえず最終的なアプリケーションコードを記載します。

構成は下記のようになります。

Model 状態を保持するクラス
Updater.Update 現在のModelとMessageから新しいModelを作成する
View.Create ModelのCellとsend(Message)関数からViewを作成する

CellはFunctional Rective Programming(FRP)でのBehaviorを意図しており、現在の値の保持と、変更されたときに通知を行うクラスだと考えてください。(詳しくは下記記事参照)
SodiumでFunctional Reactive Programming (F#) --- 導入 - 何でもプログラミング

Cellを利用することにより、値が変更されたときに勝手にUIの値が更新され、Bindingと同じ機能を実現できます。

class Model
{
    public int Count { get; }
    public Model(int count) { Count = count; }
}
abstract class Message { }
class Increment : Message { }
class Decrement : Message { }

class Updater
{
    public static Model Update(Model model, Message message)
    {
        switch (message)
        {
            case Increment msg:
                return new Model(model.Count + 1);

            case Decrement msg:
                return new Model(model.Count - 1);

            default:
                throw new Exception("invalid message");
        }
    }
}
class View
{
    public static FrameworkElement Create(ICell<Model> model, Action<Message> send)
    {
        return new Grid()
            .with_Margin(10, 10, 10, 10)
            .with_ColumnDefinitions(
                new ColumnDefinition() { Width = new GridLength(50) },
                new ColumnDefinition() { Width = new GridLength(1, GridUnitType.Star) },
                new ColumnDefinition() { Width = new GridLength(50) }
            )
            .with_Children(
                new Button()
                .with_GridColumn(0)
                .with_Content("-")
                .with_Click(() => send(new Decrement())),

                new TextBlock()
                .with_GridColumn(1)
                .with_HorizontalAlignment(HorizontalAlignment.Center)
                .with_VerticalAlignment(VerticalAlignment.Center)
                .with_Text(model.Map(x => x.Count.ToString())),

                new Button()
                .with_GridColumn(2)
                .with_Content("+")
                .with_Click(() => send(new Increment()))
            );
    }
}
Content = WpfEx.Create<Model, Message>(new Model(0), Updater.Update, View.Create);


Cell

SodiumというFRPのライブラリに、ずばりそのもののクラスが存在しますので、NuGetで取得して利用することも可能です。

今回は自前で準備してみます。

Listenした後、コールバックを解除する機能は実装してありませんので、利用する際はリークに注意してください。(静的にViewが記述されている限り問題にはならないと思います。)

public interface ICell<T>
{
    T Value { get; }
    void Listen(Action<T> f);
    ICell<U> Map<U>(Func<T, U> f);
}
public class CellSink<T> : ICell<T>
{
    List<Action<T>> _callbacks = new List<Action<T>>();
    public T Value { get; private set; }
    public CellSink(T initialValue) =>
        Value = initialValue;
    public void Listen(Action<T> f)
    {
        f(Value);
        _callbacks.Add(f);
    }
    public ICell<U> Map<U>(Func<T, U> f)
    {
        var cell = new CellSink<U>(f(Value));
        _callbacks.Add(x => cell.Send(f(x)));
        return cell;
    }
    public void Send(T value)
    {
        if (Equals(value, Value))
            return;
        Value = value;
        foreach (var f in _callbacks)
            f(value);
    }
}


with_ 関数群

Viewの記述を宣言的なものに近づけるため、各プロパティやイベントなどに対し、拡張関数を定義します。

例えばHorizontalAlignemtに固定値を割り当てる拡張関数は、下記のようになります。

public static T with_HorizontalAlignment<T>(this T element, HorizontalAlignment alignment) 
    where T : FrameworkElement
{
    element.HorizontalAlignment = alignment;
    return element;
}

イベントですと下記のようになります。

public static Button with_Click(this Button element, Action f)
{
    element.Click += (s, e) => f();
    return element;
}

また、Cellを受け取る(Binding)場合は、下記のようになります。

public static TextBlock with_Text(this TextBlock element, ICell<string> text)
{
    text.Listen(x => element.Text = x);
    return element;
}

その他、Childrenは下記のようになります。

public static T with_Children<T>(this T element, params FrameworkElement[] children)
    where T : Panel
{
    element.Children.Clear();
    children.ForEach(x => element.Children.Add(x));
    return element;
}

対象が膨大にあることと、一つの対象に対し固定値とCellを用意しなければならないため、実際に準備する際はReflectionなどを利用してコードを自動生成するのがいいと思います。

Create

Model、Updater.Update、View.Createから、実際に動作するFrameworkElementを作成する関数になります。

updatingフラグを用意することにより、View更新中にsendが送られてくるのを防いでいます。(SliderのValueをModel側から変更した際、ValueChangedでUIから更新メッセージが飛んでこないようにする等)

public static FrameworkElement Create<TModel, TMessage>(
    TModel initialModel,
    Func<TModel, TMessage, TModel> update,
    Func<ICell<TModel>, Action<TMessage>, FrameworkElement> createView)
{
    var cell = new CellSink<TModel>(initialModel);

    bool updating = false;
    Action<TMessage> send = message =>
    {
        if (updating == false)
        {
            updating = true;
            cell.Send(update(cell.Value, message));
            updating = false;
        }
    };

    return createView(cell, send);
}






既存のDependencyPropertyの変更を検知する

自前でDependencyPropertyを実装する場合は、Register時にPropertyMetadataを渡すことで、値の変更時にコールバックを呼び出すことが可能となります。

しかし既存のDependencyPropertyの場合はすでにRegisterされているため、変更を検知するには別のアプローチが必要となります。

今回は2つのアプローチを実装してみたいと思います。

なお実用上は、既存のコントロールには対応するChangedイベントが用意されているため(ActualWidthならSizeChangedなど)、DependencyPropertyの変更の検知が必要なケースはほとんどないと思います。

DependencyPropertyDescriptor

RectangleとTextBlockがあり、RectangleのActualWidthがTextBlockに表示される例を考えます。
f:id:any-programming:20170710002225p:plain

FromPropertyでDependencyPropertyDescriptorを取得し、コールバックをAddValueChangedで登録しています。

var desc = DependencyPropertyDescriptor.FromProperty(Rectangle.ActualWidthProperty, typeof(Rectangle));
desc.AddValueChanged(rectangle, (s, e) => textblock.Text = rectangle.ActualHeight.ToString());

この方法の場合、RemoveValueChangedを呼び出さない限りコントロールの参照が持ち続けられるので、コントロールを動的に削除したりする時には注意が必要です。

DependencyObjectにBindingして検知

ターゲットのDependencyPropertyにBindingして変更を検知する仲介クラス、DependencyPropertyChangedを定義してみました。

仲介用のValueプロパティを持っており、値が変化したときに登録したコールバックを呼ぶようになっています。

またConditionalWeakTableにターゲットのコントロールと共に保持することにより、ターゲットと同じ寿命になるようにしてあります。

public class DependencyPropertyChanged<T> : DependencyObject
{
    public T Value
    {
        get { return (T)GetValue(ValueProperty); }
        set { SetValue(ValueProperty, value); }
    }
    public static readonly DependencyProperty ValueProperty =
        DependencyProperty.Register("Value", typeof(T), typeof(DependencyPropertyChanged<T>), 
            new PropertyMetadata(default(T), (obj, e) => (obj as DependencyPropertyChanged<T>).OnValueChanged()));

    static ConditionalWeakTable<DependencyObject, List<DependencyObject>> _instances = new ConditionalWeakTable<DependencyObject, List<DependencyObject>>();
    Action<T> _changed;
    public DependencyPropertyChanged(DependencyObject target, string path, Action<T> changed)
    {
        _changed = changed;
        _instances.GetOrCreateValue(target).Add(this);
        BindingOperations.SetBinding(this, DependencyPropertyChanged<double>.ValueProperty, 
            new Binding(path) { Source = target, Mode = BindingMode.OneWay });
    }
    void OnValueChanged() =>
        _changed(Value);
}
new DependencyPropertyChanged<double>(rectangle, nameof(rectangle.ActualWidth), x => textboxWidth.Text = x.ToString());

この方法であれば、コントロールを動的に削除しても参照が残り続けることはなくなります。