コマンド

System.Windows.Input.ICommand

まずWPFにおけるICommand( System.Windos.Input.ICommand )について簡単におさらいしておきましょう。

ICommandはメニューやボタンから実行するコマンドを表すインターフェースです。

public interface ICommand
{
    event EventHandler CanExecuteChanged;
    bool CanExecute(object parameter);
    void Execute(object parameter);
}

コマンドを実行するExecuteおよびコマンドが実行可能かどうかを判定するCanExecuteメソッドを持ちます。 また、CanExecuteChangedイベントを持ち、コマンドが実行可能かどうか変更があった場合に通知を発生させなければなりません。

MoNo.RAILによるコマンドの生成

MoNo.RAILではICommand実装インタンスを生成する関数がいくつか用意されています。

Wpf.Command.createWpfCommand

まず最も基本的なものはMoNo.Wpf.Command.createWpfCommandです。シグネチャを確認してみましょう。

Wpf.Command.createWpfCommand : canExecute:IReactive<bool> option -> execute:('a -> unit) -> System.Windows.Input.ICommand

引数としてIReactive<bool> option型のcanExecuteと関数executeを渡し、それぞれICommandのCanExecuteとExecuteに対応付けられたICommand実装クラスが生成されます。 コマンドの実行結果としては単に関数executeが実行されます。

通常、ICommandの実装にはCanExecuteメソッドとCanExecuteChangedイベントを適切に実装する必要があります。 MoNo.RAILに用意されている一連のコマンド生成関数ではReactive<bool>型を渡すことで実行可能かどうかを決定します。 また、実行可能かどうかの変更通知もReactiveの仕組みを利用して通知するためValプロパティの値を変更するだけです。

なおcanExecuteはoptionです。Noneの場合は常に実行可能なコマンドが生成されます。

Wpf.Cont.toWpfCommand

次に引数に「実行される関数」ではなく前項で解説した「継続モナド」を渡してコマンド化する関数を見てみましょう。

Wpf.Cont.toWpfCommand : runner:IContRunner -> canExecute:#IReactive<bool> option -> cont:Cont<unit> -> System.Windows.Input.ICommand

第1引数にIContRunnerというものを渡しています。これは本項では解説しませんが継続モナドを活かすためのインターフェースです。 サンプルプログラムではViewModelがその実装クラスであるContRunnerを継承し、自身を渡すようにしています。

第3引数で関数の代わりに継続モナドを渡しています。 これにより継続モナドを呼び出すICommand実装インスタンスが生成できます。

Wpf.Command.toWpfCommand

最後は引数にMoNo.Commandのインスタンスを渡してコマンド化する関数です。

Wpf.Command.toWpfCommand : runner:IContRunner -> command:Command<'a,unit> -> System.Windows.Input.ICommand

この関数では第2引数にCommand<’a,unit>を渡します。 これはMoNo.RAILで定義されているレコード型で、

type Command<'a, 'b> = {
    CanExecute  : IReactive<bool>
    Execute     : 'a -> Cont<'b>
}

上記の定義の通り、IReactive<bool>型のCanExecuteと’aを引数に取り’bの継続モナドを返すExecute関数を持つレコードです。 それぞれICommandのCanExecuteとExecuteに対応するので説明の必要はないでしょう。

サンプルプログラム CommandSample

さて、ここまでを踏まえてサンプルプログラムを見てみましょう。 サンプルプログラムは MoNo.RAIL の下記フォルダに含まれています:

MoNo.RAIL.Samples/WpfMvvmSamples/CommandSample

外観はこのような感じです。

../../_images/command_appcapture.png

サンプルプログラム CommandSampleの仕様は以下の通りです。

  • Incrementボタンを押すと値が1増えます。
  • Decrementボタンを押すと値が1減ります。ただし値が1以上の時でないと実行できません。
  • Doubleボタンを押すと値が倍になります。
  • Div By 2ボタンを押すと値が半分になります。ただし値が10以上の時でないと実行できません。
  • 10 timesボタンを押すと3秒間待った後、値が10倍になります。待機時間中にAbortボタンが有効になり、これを押すと処理が中断されます。
  • 20 timesボタンを押すとプログレスバーが表示され3秒間待った後、値が20倍になります。こちらも待機時間中にAbortボタンが有効になり、これを押すと処理が中断されます。

まずModel.fsを見てみましょう。

type Model () =
    let value = Reactive 0
    member __.Value = value
    ...

シンプルなモデルクラスです。本質的にはReactive<int>型の値を一つ持っているだけです。

次にMainWindow.xamlを見てみましょう。

...
<Label Content="{Binding Path=Value.Val}"/>
<Button Command="{Binding Path=IncrementCommand}">Increment</Button>
<Button Command="{Binding Path=DecrementCommand}">Decrement</Button>
<Button Command="{Binding Path=DoubleCommand}">Double</Button>
<Button Command="{Binding Path=DivBy2Command}">Div By 2</Button>
<Button Command="{Binding Path=TenTimesCommand}">10 times</Button>
<Button Command="{Binding Path=TwentyTimesCommand}">20 times</Button>
...

ラベルにViewModelのValue.Valが、各ボタンにViewModelクラスが持つコマンドがそれぞれBindされています。 それではViewModel.fsで実装されている各コマンドをそれぞれ詳しく見ていきましょう。

IncrementCommand

member val IncrementCommand =
    Wpf.Command.createWpfCommand None (fun _ -> model.Increment ())

Wpf.Command.createWpfCommand に「値をインクリメントする関数」を渡してコマンドを作成しています。

第1引数はNoneなので常に実行可能なコマンドです。

DecrementCommand

member val DecrementCommand =
    Wpf.Command.createWpfCommand (Some model.CanDecrement) (fun _ -> model.Decrement ())

同様に Wpf.Command.createWpfCommand に「値をデクリメントする関数」を渡してコマンドを作成しています。

IncrementCommandと異なるのは第1引数です。実行可能かどうかはmodelのCanDecrementプロパティの値によって決定されます。

member val CanDecrement = value |> Reactive.map (fun x -> x >= 1)

値が1以上の場合にのみ実行可能であることがわかります。

DoubleCommand

member val DoubleCommand =
    cont { model.Double() }
    |> Wpf.Cont.toWpfCommand this None

modelの値を2倍する継続モナドを Wpf.Cont.toWpfCommand に渡してコマンドを作成しています。 第1引数にはContRunnerを継承しているthisを渡しています。 第2引数にはNoneを渡していますので基本的には常に実行可能なコマンドです。

が、実は実行可能でなくなるケースがあります。後述するCancelCommandの箇所で解説します。

DivBy2Command

member val DivBy2Command =
    model.DivBy2Command
    |> Wpf.Command.toWpfCommand this

modelのMoNo.Command型プロパティDivBy2Commandを Wpf.Command.toWpfCommand に渡してコマンドを作成しています。 第1引数にはやはりContRunnerであるthisを渡しています。

ではmodelのDivBy2Commandをのぞいてみましょう。

member val DivBy2Command =
    cont { value.Val <- value.Val / 2 }
    |> Command.ofCont
    |> Command.guardBy (value |> Reactive.map (fun x -> x >= 10))

Command.ofCont は継続モナドを受け取りコマンド(MoNo.Command)を作成する関数です。この関数で作成されるコマンドは常に実行可能なコマンドとなります。

さらにここではコマンドを Command.guardBy 関数に渡しています。 この関数はコマンドに実行可能な条件を付け加える関数です。ここでは値が10以上の場合に実行可能となるように条件を付加しています。

結局、DivBy2Commandは「値が10以上の場合に実行可能な、値を2で割るコマンド」となることがわかります。

TenTimesCommand

member val TenTimesCommand =
    cont {
        let x = model.Value.Val
        let! y = this.ByAsync (async {
                do! Async.Sleep 3000
                return 10 * x
            })
        model.Value.Val <- y
    }
    |> Wpf.Cont.toWpfCommand this None

DoubleCommandと同様、 Wpf.Cont.toWpfCommand に継続モナドを渡してコマンドを作成しています。 ここでは「継続モナド」の項、「ContRunnerの利用」で説明したContRunnerの ByAsync メソッドを使っています。非同期で3秒(=3000ミリ秒)待機した後、値を10倍しています。

TwentyTimesCommand

member val TwentyTimesCommand =
    cont {
        let x = model.Value.Val
        let! y = this.ByProgress (fun token ->
            for i = 1 to 100 do
                System.Threading.Thread.Sleep 30
                token.Notify 0.01
            20 * x)
        model.Value.Val <- y
    }
    |> Wpf.Cont.toWpfCommand this None

やはり Wpf.Cont.toWpfCommand に継続モナドを渡してコマンドを作成しています。 ここでは値を20倍する際に ByProgress というメソッドを使って継続モナドを作成しています。

IContRunner.ByProgrsss : computation:Func<IProgressToken,'b> -> Cont<ISession,'b>

この関数は IProgressToken を引数に取る関数を渡して継続モナドを作る関数です。 このIProgressTokenインターフェースを通して関数内での進捗を通知することができます。

具体的には Notify メソッドで進捗割合を「加算」するよう通知することができます。合計が1.0に達すると進捗率が100%ということになります。

つまりByProgressによって30ミリ秒毎に進捗率を1%ずつ進める処理を100回繰り返した後、元の値を20倍して返す継続モナドを作成していることになります。

また、 通知された進捗率はByProgressメソッドを呼び出したContRunnerの ProgressRunner.CurrentProgressPercentage に格納されます。その名の通り%で表した値が格納されます。

MainWindow.xamlをもう一度見て下さい。

...
<ProgressBar DockPanel.Dock="Bottom" Height="20"
            Value="{Binding Path=ProgressRunner.CurrentProgressPercentage, Mode=OneWay}"/>
...

プログレスバーにViewModelのProgressRunner.CurrentProgressPercentageがBindされていることが確認できます。 TwentyTimesCommandの進捗具合がこのプログレスバーに表示されるようになっているのです。

CancelCommand

member val CancelCommand =
    Wpf.Command.createWpfCommand (Some this.IsInSession) this.AbortSession

最後にCancelCommandです。

これは IsInSession がtrueの値を持つときに実行可能で、AbortSession 関数を実行するコマンドということがわかります。

ではIsInSessionやAbortSessionとは何でしょうか。 IsInSessionは継承元であるContRunnerの持つプロパティで、セッション中であることを表します。 ByAsyncに渡されたasyncコンピューテーション式の実行中や、ByProgressに渡された関数の実行中などに値がtrueになります。

そしてAbortSessionは、セッション中の継続モナドを中止する関数です。

これでCancelCommandはセッション中の場合にのみ実行可能で、セッションを中止するコマンドであることがわかりました。

ところで「10 Times」ボタンや「20 Times」ボタンを押した後、完了待ちの間にいくつかのコマンドが実行できなくなっていたことにお気づきでしょうか?

../../_images/command_appcapture_inprogress.png

DoubleCommand、DivBy2Command、TenTimesCommand、TwentyTimesCommandが実行できなくなっています。 これらのコマンドは、「常に実行可能なコマンド」として作成したはずです。 それに対して、IncrementCommandやDecrementCommandはセッション中でも実行可能になっています。 これは一体なぜでしょうか?

セッション中に実行できなくなったコマンドに共通するのは、コマンドを作成する関数にContRunnerを渡す関数でコマンドを作成したことです。

実はContRunnerを渡す関数で作成されたコマンドが実行可能かどうかは「引数で渡した実行可能フラグがtrueかどうか」だけでなく、 さらに「引数で渡したContRunnerがセッション中でない」という条件が暗黙的に付加されます。 このためセッション中はDoubleCommand、DivBy2Command、TenTimesCommand、TwentyTimesCommandが実行できなくなるというわけです。

この仕組みをうまく活用することで、重い処理を行っている最中に余計なコマンドを実行できないようにすることができます。

逆にCancelCommandを

member val CancelCommand =
  cont {
    this.AbortSession()
  }
  |> Wpf.Cont.toWpfCommand this (Some this.IsInSession)

と実装してしまうと、「セッション中」かつ「セッション中でない」場合に実行可能なコマンド、つまりは「常に実行できない」コマンドが実装されてしまうことになります。