Reactive Extensionsつまみ食い(2) イベントの合成でマウスジェスチャ

前回に続いて今回もReactive Extensionsによるマウスイベントの合成を取り上げます。
今回は、マウスジェスチャを実装してみたいと思います。

マウスジェスチャの仕様

実装するマウスジェスチャ処理を次のように定義します。

ジェスチャの登録
  • マウスポインタの移動方向を矢印(↑、↓、←、→の文字)に置き換える。
  • この文字を組み合わせた文字列をジェスチャパターンとして登録する。

例:"↑↓↑" ⇒「上、下、上」の順でマウスを移動

  • ジェスチャパターンと、それに対応するコマンドのペアを辞書に登録します。
  • 対応するコマンドはActionデリゲートとします。
移動方向の検出
  • マウスボタンを押下しながらマウスポインタを一定量移動した場合に、移動方向が検出されます。
  • 但し、同じ方向は連続して検出しない。
  • 登録した中で最も長いジェスチャパターンの長さまで検出可能とする。
コマンドの実行
  • マウスボタンを放したタイミングで、辞書に登録したジェスチャパターンから検出したパターンと一致するものを検索する。
  • 一致するパターンが見つかったら、対応するコマンドを実行する。

MouseGestureクラス

以上を踏まえ、マウスジェスチャの処理を実装してみましょう。
まず、マウスジェスチャの処理を行うMouseGestureクラスを定義します。

using System;
using System.Collections.Generic;
using System.Reactive.Linq;
using System.Windows.Forms;

/// <summary>
/// マウスジェスチャ管理クラス
/// </summary>
public class MouseGesture
{
  /// <summary>マウスジェスチャ登録用辞書</summary>
  private Dictionary<string, Action> gestures;
  /// <summary>コマンド最大文字数</summary>
  private int maxCount;
  /// <summary>コンストラクタ</summary>
  public MouseGesture()
  {
    this.gestures = new Dictionary<string, Action>();
  }

  /// <summary>
  /// ジェスチャの追加
  /// </summary>
  /// <param name="gesture">ジェスチャパターン</param>
  /// <param name="command">コマンド</param>
  public void Add(string gesture, Action command)
  {
    this.gestures.Add(gesture, command);
    //最長となるジェスチャパターンの文字数を設定
    if (this.maxCount < gesture.Length)
      this.maxCount = gesture.Length;
  }

  /// <summary>
  /// マウスジェスチャ検出開始
  /// </summary>
  /// <param name="target">マウスジェスチャを動作させるコントロール</param>
  /// <param name="button">マウスボタン</param>
  /// <param name="interval">移動方向の検出に必要な距離(画素数)</param>
  public void Start(Control target, MouseButtons button, int interval)
  {
    //ここでRxによるイベントの合成を行い
    //マウスジェスチャを検出する処理を記述
  }

このクラスを使用するForm側の処理は次のようになります。

public Form1()
{
  var gesture = new MouseGesture();
  //ジェスチャ登録
  gesture.Add("→←→", () => コマンド1 );
  gesture.Add("↑↓", () => コマンド2 );
  gesture.Add("↑→↓←", () => コマンド3 );
  //ジェスチャ検出開始
  gesture.Start(this, MouseButtons.Right, 30);

マウスジェスチャ検出処理

では、Rxによるイベントの合成を利用したマウスジェスチャの検出処理を見てみましょう。

/// <summary>
/// マウスジェスチャ検出開始
/// </summary>
/// <param name="target">マウスジェスチャを動作させるコントロール</param>
/// <param name="button">マウスボタン</param>
/// <param name="interval">移動方向の検出に必要な距離(画素数)</param>
public void Start(Control target, MouseButtons button, int interval)
{
  var move = target.MouseMoveAsObservable();
  var up = target.MouseUpAsObservable().Where(e => e.Button == button);

  this.disposable =
    target.MouseDownAsObservable().Where(e => e.Button == button)  //…(1)
      .SelectMany(arg0 =>                                          //…(2)
      {
        return move.TakeUntil(up)                                  //…(3)
          .Select(arg1 =>                                          //…(4)
          {
            //マウスの移動方向を↑,↓,←,→の文字に置き換える
            var arrow = GetArrowChar(arg1.Location.X - arg0.Location.X,
                                     arg1.Location.Y - arg0.Location.Y,
                                     interval);
            //方向を検知(interval以上移動)した場合起点を更新
            if (arrow != char.MinValue) 
              arg0 = arg1;
            return arrow;
          })
        .Where(arrow => arrow != char.MinValue)              //…(5)
        .DistinctUntilChanged()                              //…(6)
        .Take(this.maxCount)                                 //…(7)
        .Aggregate(string.Empty,
          (gesture, arrow) =>gesture + arrow)                //…(8)
        .Zip(up, (gesture, _) => gesture);                   //…(9)
      })
      .Subscribe(gesture =>                                  //…(10)
      {
        //流れてきた文字列と一致するジェスチャパターンが存在したら
        //対応するコマンドを実行
        Action command;
        if (gestures.TryGetValue(gesture, out command))
        {
          command();
        }
      });
    }

(1) マウスの指定ボタンが押されたのをきっかけに処理が流れ出します。
(2) SelectManyで次のイベントへ接続します。デリゲートの引数arg0にはMouseDown時のイベントデータが渡されます。
(3) 押されているボタンが放されるまでのMouseMoveイベントを通します。
(4) マウスの移動方向を文字(矢印)に変換します。
(5) 変換出来た場合(指定距離以上移動した場合)のみ後続へ流します。
(6) 同じ文字(矢印)は連続して通さないようにします。
(7) ここに流れてきた文字(矢印)の数が最大に達したらそれ以降は通しません。
(8) 文字を連結して文字列に変換します。
(9) MouseUpイベントが来るまで待機します。押されていたマウスボタンが放されたタイミングでSubscribeに処理が流れます。(※この処理が無いと、Take(maxCount)で最大数をカウントした時点でSubscribeに流れてしまいます。)
(10) 流れてきた文字列をキーに、ジェスチャパターンの辞書を検索し、一致するものが存在したら対応するコマンドを実行します。

補足

移動方向を矢印に変換するメソッドは次の通りです
移動量がintervalに指定した画素数未満であった場合、未検知としてchar.MinValue(=0)を返します。

/// <summary>
/// 矢印の取得
/// </summary>
/// <param name="dx">移動量x</param>
/// <param name="dy">移動量y</param>
/// <param name="interval">矢印の取得に必要な移動量</param>
/// <returns>移動方向を表す文字(↑,↓,←,→)</returns>
private char GetArrowChar(int dx, int dy, int interval)
{
  int px = Math.Abs(dx);
  int py = Math.Abs(dy);
  if (px > py)
  {
    if (px < interval)
      return char.MinValue;
    return (dx > 0) ? '→' : '←';
  }
  else
   {
    if (py < interval)
      return char.MinValue;
    return (dy > 0) ? '↓' : '↑';
  }
}

また、このメソッドで方向を検出した場合、次の処理でSelectManyに流れてくるMouseDown時のイベント引数を現在の値に書き換えています。少し強引ですが、これで次の方向を検出する為の起点を更新しています。

サンプル

マウスジェスチャ処理を簡易ドローツール(Wordのオートシェイプのようなもの)に実装したサンプルを作成してみました。

以下のURLからソースコードをダウンロードできます。
https://github.com/csharpkun/sandbox/tags
・MouseGesture_Example.zip

プログラムは以下の環境で作成しました。

ソリューションRxExample.slnに次の2つのプロジェクトがあり、

  • ReactiveDrawing //処理ロジックをまとめたクラスライブラリ
  • ReactiveDrawer //WinFormsアプリケーション

マウスジェスチャ処理クラスはReactiveDrawing.MouseGesture.csにあります。

UI側の処理はメインフォームのコンストラクタのみに記述しています。

//メインフォーム コンストラクタ
public DrawingForm()
{
  InitializeComponent();
  this.DoubleBuffered = true;
 //ドローツールを生成 
  var drawingManager = new DrawingManager(new RectanglePen(Color.Blue));
  //ドローツール操作受付開始
  drawingManager.Start(this, MouseButtons.Left);
  //ペイントイベント。図形を描画する。
  this.Paint += (o, e) => drawingManager.Draw(e.Graphics);

  //マウスジェスチャ処理オブジェクト生成
  var gesture = new MouseGesture();
  //タイトルバーに矢印を表示する為のイベント
  gesture.DirectionCaptured += (o, e) => this.Text = e.Gesture;
  //ジェスチャの登録
  gesture.Add("→←→",() =>
    {
      drawingManager.Clear();
      this.Text = "クリア";
      this.Refresh();
    });
  gesture.Add("↑↓",() =>
    {
      drawingManager.DefaultItem = new EllipsePen(Color.Red);
      this.Text = "楕円";
      this.Refresh();
    });
  gesture.Add("↑→↓←",() =>
    {
      drawingManager.DefaultItem = new RectanglePen(Color.Blue);
      this.Text = "四角形";
      this.Refresh();
    });
  gesture.Add("↓→↑",() =>
    {
      drawingManager.DefaultItem = DrawingManager.Selector;
      this.Text = "選択";
      this.Refresh();
    });
  //マウスジェスチャ検出開始
  gesture.Start(this, MouseButtons.Right, 30);
}

WinFormsアプリケーションのFormクラスはイベントハンドラだらけになりがちですが、うそのようにスッキリしているではありませんか!


ちなみに、簡易ドローツールのマウス処理もRxを利用して実装しています。
ReactiveDrawing.Shapes名前空間に定義した図形オブジェクトの追加、リサイズ等をReactiveDrawing.DrawingManagerクラスで制御しています。
(本題のマウスジェスチャよりもこちらの方が規模が大きくなってしまいました。
実は記事がなかなかまとまらず、コーディングに逃避していたらいつの間にかコードが膨らんでしまい。。。
反省はしていませんが。)

実行例

1. 最初は左ドラッグで四角形の描画ができます

2. 右ドラッグで「↑↓」とマウスを移動します。方向を検出したらタイトルバーにその矢印が表示されます

3. マウスボタンを放すと、タイトルバーに「楕円」と表示され、左ドラッグで楕円が描画されます

4. 右ドラッグで「↓→↑」と入力

5. マウスボタンを放すと、タイトルバーに「選択」が表示され、左ドラッグによる図形オブジェクトの選択が可能となります

6. 選択枠で囲った図形オブジェクトが選択状態に

7. 右ドラッグで「→←→」と入力

8. マウスボタンを放すと、タイトルバーに「クリア」が表示され、図形オブジェクトがすべて削除されます。

まとめ

今回、以下のパターンで実装を行いました。

  1. 関連するイベント処理をRxで纏め、専用クラスに分離する。
  2. 使用する側では処理クラスのインスタンスを生成して、Startメソッドで入力を待ち受ける。
  3. また、サンプルでは使用していませんが、Stopメソッドでイベントの待ち受けを停止することもできます(Subscribeメソッドから返るIDisposableを使用したイベントのデタッチ)。

これによりUI側からイベントハンドラが消え、目的別にまとめられた処理は簡単に実行/停止できるようになりました。

参考資料

この記事を作成するに当たり、以下のサイトを参考にさせていただきました。

Reactive Extensionsつまみ食い(1)

はじめまして

画像処理系のソフトウェアを開発しているプログラマです。
仕事でC#を使い始めて約2年ほど経ち、ようやく手になじんできました。
とはいえ、.Netの世界は広く深いので、まだまだほんの入り口に足を踏み入れた程度でしょう。

このブログでは、C#、.Net関連の話題を中心にプログラミングに関する記事を書く予定です。
仕事では試せないことや、日々気になったトピックなどを取り上げたいと思います。

仕事と家事と子守りの合間を縫って、1日1行でもコーディングを続けたいと思いこのブログを始めました。
記事の更新はマイペースでやっていきたいと思いますが、どうぞよろしくお願いします。

第1回目のテーマは - Reactive Extensions -

ブログの最初のテーマですが、
Twitterで度々話題にあがっていてずっと気になっていたReactive Extensions(Rx)を試してみたいと思います。
ちょうど良いタイミングで@ITに「Reactive Extensions(Rx)入門 」が連載中でしたので、この記事を参考にRxをつまみ食いしてみたいと思います。

今回は「第2回 イベント・プログラミングとRx」から。
http://www.atmarkit.co.jp/fdotnet/introrx/introrx_02/introrx_02_02.html

この記事で紹介されている、マウスイベントの合成について試してみたいと思います。
なお、作成するアプリケーションはWindows Formsアプリケーションとします。

イベントからIObservableを生成する

まずは、マウスイベントをObservableオブジェクトに変換します。

Observable.FromEventメソッドを使用して、MouseDown、MouseMove、MouseUpの各イベントをObservable化します。
今回はWindowsFormsアプリケーションですので、System.Windows.Forms.Controlクラスの拡張メソッドとして纏めます。

public static class ControlExtensions
{
  //マウスダウンイベント
  public static IObservable<MouseEventArgs> MouseDownAsObservable(this Control control)
  {
    return Observable.FromEvent<MouseEventHandler, MouseEventArgs>(
             h => (o, e) => h(e),
             h => control.MouseDown += h,
             h => control.MouseDown -= h);
  }
  //マウスムーブイベント
  public static IObservable<MouseEventArgs> MouseMoveAsObservable(this Control control)
  {
    return Observable.FromEvent<MouseEventHandler, MouseEventArgs>(
             h => (o, e) => h(e),
             h => control.MouseMove += h,
             h => control.MouseMove -= h);
  }
  //マウスアップイベント
  public static IObservable<MouseEventArgs> MouseUpAsObservable(this Control control)
  {
    return Observable.FromEvent<MouseEventHandler, MouseEventArgs>(
             h => (o, e) => h(e),
             h => control.MouseUp += h,
             h => control.MouseUp -= h);
  }
}

マウスドラッグイベントの合成

これらObservable化されたマウスイベントからマウスドラッグイベントを合成します。
ドラッグイベントを生成する処理も、他のマウスイベントと同様に拡張メソッドとして実装します。

//マウスドラッグイベント生成 拡張メソッド
public static IObservable<MouseDragEventArgs> MouseDragAsObservable(
                this Control control, MouseButtons mouseButton)

戻り値はドラッグ処理中に必要なデータをまとめた自作イベント引数のObservableオブジェクトです。

//マウスドラッグイベント引数
public class MouseDragEventArgs : EventArgs
{
  //マウスボタン押下位置
  public Point StartLocation { set; get; }
  //直前のマウス位置
  public Point LastLocation { set; get; }
  //現在のマウス位置
  public Point Location { set; get; }
  //コンストラクタ
  public MouseDragEventArgs(Point startLocation, Point lastLocation, Point Location)
  {
    this.StartLocation = startLocation;
    this.LastLocation = lastLocation;
    this.Location = Location;
  }
}

ドラッグ中の処理では、現在のマウス位置に加え、ドラッグ開始時のマウス位置や直前のマウス位置が必要となることが多々あります。これらがまとまってSubscribeへと流れるようにイベントデータを加工します。

また、拡張メソッドの引数 mouseButtonでドラッグイベントが発動するボタンを指定するようにしました。
これで右ボタンドラッグ、左ボタンドラッグ等、押下されたボタンに応じた処理を別々に記述することが可能となります。

合成処理 詳細

以上を踏まえて、ドラッグイベントを合成する処理の中身を実装してみます。

//マウスドラッグイベント生成 拡張メソッド
public static IObservable<MouseDragEventArgs> MouseDragAsObservable(
                this Control control, MouseButtons mouseButton)
{
  //マウスダウンイベント
  var down = control.MouseDownAsObservable()
               .Where(e => e.Button == mouseButton); //指定ボタンのみ通す
  //マウスムーブイベント
  var move = control.MouseMoveAsObservable();
  //マウスアップイベント
  var Up = control.MouseUpAsObservable()
             .Where(e => e.Button == mouseButton); //指定ボタンのみ通す

  return down      //指定ボタンが押下されたら
    .SelectMany(   //次のイベントを連結する
      e0 =>
      {
        return move
          .TakeUntil(Up)  //ボタンが放されるまでのマウスムーブを取得する。
          //イベントデータをMouseDragEventArgsに加工する
          .Select(e => new MouseDragEventArgs(e0.Location, e0.Location,e.Location))
          .Scan((e1, e2) => new MouseDragEventArgs(e1.StartLocation, e1.Location,e2.Location));
      });
}

合成の流れですが、基本的には@ITの連載記事で紹介されているマウスドラッグイベントと同様です。
記事では次のような流れとなっています。

  • MouseDownからSelectMenyメソッドでMouseMoveに接続
  • TakeUntilメソッドでMouseUpが発行されるまでMouseMoveを通す
  • SelectメソッドでMouseEventArgsからマウス位置(X,Y)を抽出

今回はSelectメソッド以降の処理を変更してイベントデータを前述のMouseDragEventArgsのかたちに加工します。

まず、Selectメソッド内でMouseDown時のマウス位置(e0.Location)と現在のマウス位置(e.Location)使用してMouseEventArgsを生成します。この時点では直前のマウス位置(LastLocation)は取得できませんので、MouseDown時のマウス位置を入れておきます。

直前のマウス位置の付加は、Scanメソッドで行います。
連載記事では、「Scanメソッドは1つ前の「結果」と現在の「値」を合成して値を流す。」と説明されています。
Scanメソッドに渡すデリゲートの引数には、「e1」に1つ前の(Scanの)結果、「e2」に現在の(Selectから流れてくる)値が渡されます。

そこで、以下の通りにMouseEventArgsを生成します。

  • StartLocation = 1つ前のボタン押下位置(e1.StartLocation)
  • LastLocation = 1つ前のマウス位置(e1.Location)
  • Location = 現在のマウス位置(e2.Location)

また、MouseDown(およびMouseUp)イベントでは、指定したボタンを押下した(放した)場合のみを通過させるよう、Whereメソッドでフィルタリングしています。

使用例

次のコードは、マウスドラッグイベントを使用した簡単なプログラムの例です。
左ボタンドラッグで直線、右ボタンドラッグで四角形を描画します。

//Form1コンストラクタ
private Form1()
{
  
  var line= new Point[2];       //直線座標
  var rect = new Rectangle();   //矩形(四角形)座標
  
  //左ボタンドラッグ
  this.MouseDragAsObservable(MouseButtons.Left)
     .Subscribe(
       e=>
       {
         //マウス押下位置から現在位置まで直線を引く
         line[0] = e.StartLocation;
         line[1] = e.Location;
         this.Refresh();  //描画面更新
       });
  
  //右ボタンドラッグ
  this.MouseDragAsObservable(MouseButtons.Right)
     .Subscribe(
       e=>
       {
         //マウス押下位置から現在位置までを対角線とする四角形を描画
         rect = new Rectangle(e.StartLocation, 
                              new Size(Math.Abs(e.Location.X-e.StartLocation.X),
                                       Math.Abs(e.Location.Y-e.StartLocation.Y)));
         this.Refresh();  //描画面更新
       });
       
  //ペイントイベント
  this.Paint += 
    (sender,e)=>
    {
      //図形の描画を実行
      e.Graphics.DrawLine(Pens.Red, line[0], line[1]);
      e.Graphics.DrawRectangle(Pens.Blue, rect);
    }
}

従来のイベント処理ではMouseDownでボタン押下時のマウス位置を始点に設定、MouseMoveで現在のマウス位置を終点に設定のようにイベント毎に分散していた処理が、Subscribe内にまとめて記述可能となりました。
また、押下されたボタンの種類によって異なる処理も、イベント内で条件分岐する必要なくスッキリと記述できました。

まとめ

今回は比較的単純な処理のサンプルでしたが、十分にRxの威力を感じとることができました。

Rxによるイベントの合成のポイント!
  • 複雑なイベントの組み合わせも、Observableのメソッドチェーンにまとめる事で処理の流れが追いやすくなる。
  • 実現したいロジックそのものと、イベントの状態管理部分とを分離することで、見通しが良くなるだけでなく、再利用もしやすくなる。

次回

次回も引き続きReactive Extensionsによるイベントの合成で行きたいと思います。
テーマは「Rxでマウスジェスチャ―処理を作る」です。