UI 最前線

Windows Phone でのタッチ ジェスチャ

Charles Petzold

コード サンプルのダウンロード

image: Charles PetzoldAPI の進化を観察することに専門家として多くの時間を費やしながら、マルチタッチがかかわる API の広い範囲のほんの片隅で楽しい経験をたくさん積んできました。Windows Presentation Foundation (WPF)、Microsoft Surface、Silverlight、XNA、および Windows Phone にはさまざまなマルチタッチ API があり、その数を数えようとも思いませんが、これらの中でマルチタッチの "統一理論" がいまだ明確になっていないことは間違いありません。

もちろん、このようにマルチタッチ API がたくさん存在することは、比較的新しいテクノロジであれば驚くべきことではありません。さらに、マルチタッチはマウスよりも複雑です。マルチタッチは、一部、複数の指による操作になり、マウスなどの完全な人工デバイスによる操作との違いも反映されます。人間はこれまでの人生経験を指を使って過ごしてきたため、ビデオ ディスプレイの光沢のある表面に触れるときでも、これまで慣れている方法で操作すると考えられます。

アプリケーション プログラマにとっては、Windows Phone 7 は 4 種類 (そう、4 種類です) のタッチ インターフェイスを定義します。

Windows Phone 7 向けに作成する Silverlight アプリケーションでは、Touch.FrameReported 静的イベントを通じた低レベルのタッチ入力と、さまざまな Manipulation ルーティング イベントを通じた高レベルのタッチ入力を利用できます。これらの Manipulation イベントは、WPF の類似イベントのサブセットですが、両者には大きな悩みの種となるほどの違いがあります。

Windows Phone 7 向けの XNA アプリケーションでは、TouchPanel 静的クラスを使用してタッチ入力を取得しますが、この 1 つのクラスには実際は 2 つのタッチ インターフェイスが組み込まれています。GetState メソッドは低レベルの指の操作を取得し、ReadGesture メソッドは高レベルのジェスチャを取得します。ReadGesture メソッドは、チェックマーク、円などのスタイラス ペンのようなジェスチャはサポートせず、タップ、ドラッグ、ピンチといった名前で表される、ごく簡単なジェスチャをサポートします。XNA アーキテクチャに合わせて、タッチ入力は、イベントを通じて配信されるのではなく、アプリケーションからポーリングします。

Silverlight に組み込まれるジェスチャ

Silverlight for Windows Phone 7 には、既にマルチタッチ API が十分に用意されていると当然のように思っていたため、3 つ目のタッチ インターフェイスが追加されたと知りとても驚きました。このタッチ インターフェイスが追加されたツールキットは、私自身の著書『Programming Windows Phone 7』(Microsoft Press、2010 年) で説明するには少し間に合いませんでした。

ご存知かもしれませんが、過去数年にわたる WPF と Silverlight のさまざまなリリースは、CodePlex からツールキットをリリースすることで補完されてきました。これらのツールキットは、通常の出荷サイクル以外に、マイクソフトから開発者に新しいクラスを提供することを可能にします。また、今後のリリースに組み込む予定のフレームワークの機能強化を "先行して" 提供することもできます。完全なソース コードは、大きな特典です。

Windows Phone 7 でも CodePlex からメリットが得られるようになります。Silverlight for Windows Phone Toolkit (silverlight.codeplex.com (英語) から入手可能) には、Windows Phone 7 ユーザーにとってはおなじみの DatePicker、TimePicker、および ToggleSwitch の各コントロール、WrapPanel クラス (携帯電話の向きの変化に対処するのに便利です)、マルチタッチ ジェスチャのサポートなどが用意されています。

ツールキットに含まれる新しい Silverlight ジェスチャのサポートは、意図的に XNA の TouchPanel.ReadGesture メソッドに似せて設計されています。ただし、ポーリングではなく、ルーティング イベントを通じて配信されます。

どの程度似ているかと言うと、想像よりもはるかに多くの点が似ています。ソース コードを見てみると、これらの新しい Silverlight ジェスチャ イベントは、XNA の TouchPanel.ReadGesture メソッドへの呼び出しから完全に派生されていることに非常に驚きました。Windows Phone 上の Silverlight アプリケーションから、この XNA メソッドを呼び出すことができるとは思ってもみなかったのですが、実際には可能なのです。

Silverlight と XNA のジェスチャは非常に似ているにもかかわらず、ジェスチャに関連するプロパティは似ていません。たとえば、XNA プロパティではベクターを使用しますが、Silverlight には Vector 構造体が含まれていないため (この構造体の省略は愚かなことだと思います)、このプロパティを簡単な方法で Silverlight 用に再定義する必要があります。

私はこのようなジェスチャ イベントを使用した経験があるため、Silverlight for Windows Phone のお気に入りのマルチタッチ API になっています。これらのジェスチャ イベントを使用すると必要な処理の多くを実行でき、非常に使いやすくもあります。

これらのジェスチャを実際に使用して説明しましょう。

ジェスチャ サービスとリスナー

今月のコラムのすべてのソース コードは、GestureDemos というダウンロード可能な Visual Studio ソリューションに含めてあります。このソリューションには 3 つのプロジェクトがあります。Windows Phone 7 開発ツールと、もちろん、Silverlight for Windows Phone Toolkit もインストールする必要があります。

ツールキットをインストール後、Microsoft.Phone.Controls.Toolkit アセンブリへの参照を追加すると、独自の Windows Phone プロジェクトで使用できるようになります。このアセンブリは、[参照の追加] ダイアログ ボックスの [.NET] タブの下に表示されます。

XAML ファイルで、次のような XML 名前空間宣言が必要になります (実際には、すべて 1 行に記述します)。

xmlns:toolkit=
"clr-namespace:Microsoft.Phone.Controls;
assembly=Microsoft.Phone.Controls.Toolkit"

使用可能な 12 個のジェスチャ イベントを、説明する順番に大まかに次に示します (1 行にまとめたイベントには関連性があり、順番に発生します)。

GestureBegin, GestureCompleted
Tap
DoubleTap
Hold
DragStarted, DragDelta, DragCompleted
Flick
PinchStarted, PinchDelta, PinchCompleted

Grid コントロールまたは Grid の子コントロールで発生する Tap イベントと Holdイベントを処理するとします。XAML ファイルでは次のように指定できます。

<Grid ... >
  <toolkit:GestureService.GestureListener>
    <toolkit:GestureListener 
      Tap="OnGestureListenerTap"
      Hold="OnGestureListenerHold" />
  </toolkit:GestureService.GestureListener>
    ...
</Grid>

GestureService クラスの添付プロパティの GestureListener の子である GestureListener タグに、イベントおよびハンドラーを指定します。

または、コード内に、Microsoft.Phone.Controls 名前空間の名前空間ディレクティブと次のコードを含める必要があります。

GestureListener gestureListener = 
  GestureService.GetGestureListener(element);

gestureListener.Tap += OnGestureListenerTap;
gestureListener.Hold += OnGestureListenerHold;

いずれ場合も、このジェスチャ リスナーをパネルで指定するのであれば、Background プロパティは少なくとも Transparent に設定するようにします。Background プロパティに既定値の null を設定すると、単純にイベントがパネルで受け取れなくなります。

タップとホールド

すべてのジェスチャ イベントには、GestureEventArgs 型のイベント引数、または GestureEventArgs から派生した型のイベント引数が付属します。OriginalSource プロパティは、最初に指で触れた画面の最上位要素を示します。GetPosition メソッドは、指が現在触れている位置の任意の要素からの相対座標を返します。

ジェスチャ イベントはルーティングされます。つまり、ビジュアル ツリーを上方向に移動でき、GestureListener プロパティがインストールされている任意の要素で処理できます。通常、イベント ハンドラーでは、GestureEventArgs の Handled プロパティを true に設定し、イベントがビジュアル ツリーを上方向にそれ以上移動できないようにします。ただし、この設定は、これらのジェスチャ イベントを使用する他の要素のみに影響します。Handled プロパティを true に設定しても、ビジュアル ツリーの高い位置にある要素が他のインターフェイスを通じてタッチ入力を取得するのを避けることはできません。

GestureBegin イベントは、それまで触れられていない画面に指で触れたことを示します。GestureCompleted イベントは、すべての指が画面から離れたことを通知します。これらのイベントは初期化やクリーンアップに役に立つ可能性がありますが、通常は、これらの 2 つのイベントの間に発生するジェスチャ イベントに注目することになります。

簡単なジェスチャについてはあまり多く説明しません。Tap イベントは、指で画面に触れ、触れた位置からあまり動かず、約 1.1 秒以内に指を離すと発生します。間を置かずに連続 2 回タップすると、2 回目のタップは DoubleTap イベントとして通知されます。Hold イベントは、指で画面に触れ、ほぼ同じ位置に約 1.1 秒とどまると発生します。指が画面から離れるのを待つことなく、約 1.1 秒経過すると発生します。

ドラッグとフリック

ドラッグのシーケンスは、DragStarted イベント、0 または 1 つ以上の DragDelta イベント、および DragCompleted イベントで構成され、指で画面に触れたまま移動し、画面から離れると発生します。指で最初に画面に触れたときはドラッグが行われるかどうかわからないため、指が実際に Tap イベントのしきい値を超えて移動するまで、DragStarted イベントの発生は遅延されます。指が画面上で約 1 秒間移動しなければ、DragStarted イベントより先に Hold イベントが発生します。

DragStarted イベントが発生しているときは、指が既に移動を始めているため、DragStartedEventArgs オブジェクトに Orientation 型の Direction プロパティ (Horizontal または Vertical) が含まれています。DragDelta イベントに付属している DragDeltaEventArgs オブジェクトには、TranslateTransform クラスの X プロパティと Y プロパティに加算するのに適した HorizontalChange プロパティと VerticalChange プロパティ、または添付プロパティの Canvas.Left プロパティと Canvas.Top プロパティなどの詳細情報が含まれます。

Flick イベントは、ユーザーが慣性が働くことを想定して、画面上で指を移動しながら離したときに発生します。イベントの引数には、Angle 値 (正の X 軸から時計回りに測定されます) と、1 秒あたりのピクセル数で表される HorizontalVelocity 値と VerticalVelocity 値が含まれます。

Flick イベントは単独で発生することも、DragStarted イベントと DragCompleted イベントの間に DragDelta イベントが呼び出されないときに発生することもあります。また、一連の DragDelta イベントの後、DragCompleted イベントの前に発生することもあります。通常、Drag イベントと Flick イベントは併せて処理することになり、多くの場合、Flick イベントは Drag イベントの延長のように発生します。いずれにせよ、独自の慣性ロジックを追加する必要があります。

このロジックは、DragAndFlick プロジェクトで例を示しています。画面には、ユーザーが指で周囲を単純にドラッグする楕円が表示されます。フリック操作を行って画面から指を離すと、Flick イベントが発生し、Flick ハンドラーが情報を保存し、CompositionTarget.Rendering イベントのハンドラーをインストールします。このイベントは、ビデオ ディスプレイの更新と同期して発生し、速度を減速しながら、楕円を動かし続けます。

ディスプレイの各辺からの跳ね返りは、少し変わった方法で処理します。プログラムでは、楕円が停止するまで同じ方向に単純に移動し続けるように位置を管理しますが、楕円が跳ね返る領域に達すると折り返されるよう位置を変更します。

つねって、夢かもしれない

ピンチ (つねる) のシーケンスは、2 本の指で画面に触れているときに発生します。一般に、画面上のオブジェクトの拡大や縮小と解釈され、回転と解釈されることもあります。

ピンチ操作は、マルチタッチ処理の不安定な分野の 1 つであることは間違いありません。そのため、高レベルのインターフェイスで適切な情報を提供するのに失敗するのは珍しいことではありません。ご存じのとおり、Windows Phone 7 の ManipulationDelta イベントは特に扱いにくいイベントです。

ジェスチャを処理する際、ドラッグのシーケンスとピンチのシーケンスが同時に発生することはありません。両者が重なり合って発生することはありませんが、連続して発生する可能性はあります。たとえば、指で画面に触れてドラッグするとします。すると、1 つの DragStarted イベントと複数の DragDelta イベントが発生します。ここで、もう 1 本の指が画面に触れるとします。すると、ドラッグのシーケンスを完了する DragCompleted イベントが発生し、続いて 1 つの PinchStarted イベントと複数の PinchDelta イベントが発生します。次に、1 本の指を動かしながら、もう 1 本の指を離します。すると、ピンチのシーケンスを完了する PinchCompleted イベントが発生し、続いて DragStarted イベントと DragDelta イベントが発生します。基本的に、画面に触れている指の数に応じて、ドラッグのシーケンスとピンチのシーケンスが交互に切り替わります。

このピンチ ジェスチャの便利な特徴は、情報が破棄されないことです。イベント引数のプロパティを使用して、2 本の指の位置を完全に再構築できるため、必要に応じて、最初の原理にいつでも戻ることができます。

ピンチのシーケンスの間、1 本目の指 ("最初の指" と呼ぶことにしましょう) の現在位置は、常に GetPosition メソッドで取得できます。説明にあたって、この戻り値を pt1 と呼ぶことにします。PinchStarted イベントの PinchStartedGestureEventArgs クラスには、最初の指に対する 2 本目の指の相対位置を示す、Distance と Angle という 2 つの追加プロパティがあります。次のステートメントを使用して、2 本目の指の実際の位置を簡単に計算できます。

Point pt2 = new Point(pt1.X + args.Distance * Cos(args.Angle),
                      pt1.Y + args.Distance * Sin(args.Angle));

Angle プロパティは度単位のため、Math.Cos メソッドと Math.Sin メソッドを呼び出す前に、Cos メソッドと Sin メソッドを使用してラジアン値に変換する必要があります。また、PinchStarted ハンドラーが完了する前に、pinchStartDistance および pinchStartAngle というフィールドに、Distance プロパティと Angle プロパティを保存しておきます。

PinchDelta イベントには、PinchGestureEventArgs オブジェクトが付属します。ここで再び、GetPosition メソッドから最初の指の位置を取得します。これは元の位置から移動している可能性があります。2 本目の指には、イベント引数として DistanceRatio プロパティと TotalAngleDelta プロパティがあります。

DistanceRatio プロパティは、指が移動する前の元の距離と、移動後の現在の距離の比率です。つまり、現在の距離は次のように計算できます。

double distance = args.DistanceRatio * pinchStartDistance;

TotalAngleDelta プロパティは、指が移動する前の元の角度と、移動後の現在の角度の差異です。現在の角度は次のように計算できます。

double angle = args.TotalAngleDelta + pinchStartAngle;

ここで、先ほどと同じように、2 本目の指の位置を計算できます。

Point pt2 = new Point(pt1.X + distance * Cos(angle),
                      pt1.Y + distance * Sin(angle));

PinchDelta イベント実行中に、それ以降の PinchDelta イベントを処理するために、フィールドに追加情報を保存する必要はありません。

TwoFingerTracking プロジェクトは、画面上で 1 本または 2 本の指を追跡する青と緑の楕円を表示して、このロジックを示しています。

拡大縮小と回転

PinchDelta イベントは、オブジェクトの拡大縮小と回転を実行できるだけの情報も提供します。独自の行列乗算メソッドを提供する必要がありますが、これは大変厄介です。

この例を示すため、ScaleAndRotate プロジェクトは、写真のドラッグ、拡大縮小、オプションで回転を実行できる、"伝統的な" 形式のデモを実装します。このような変換を実行するには、RenderTransform プロパティを二重に囲む Image 要素を定義します (図 1 参照)。

図 1 ScaleAndRotate プロジェクトの Image 要素

<Image Name="image"
  Source="PetzoldTattoo.jpg"
  Stretch="None"
  HorizontalAlignment="Left"
  VerticalAlignment="Top">
  <Image.RenderTransform>
    <TransformGroup>
      <MatrixTransform x:Name="previousTransform" />

        <TransformGroup x:Name="currentTransform">
          <ScaleTransform x:Name="scaleTransform" />
          <RotateTransform x:Name="rotateTransform" />
          <TranslateTransform x:Name="translateTransform" />
        </TransformGroup>
    </TransformGroup>
  </Image.RenderTransform>
</Image>

ドラッグ操作またはピンチ操作の実行中に、入れ子になっている TransformGroup クラスの 3 つの変換処理を行って、画面上での写真の移動、拡大縮小、および回転を行います。DragCompleted イベントまたは PinchCompleted イベントが発生すると、previousTransform という MatrixTransform 行列と、TransformGroup クラスの Value プロパティとして使用できる複合変換とを乗算します。その後、この TransformGroup クラスの 3 つの変換を既定値に戻します。

拡大縮小と回転は、常に、変換が行われても位置が変わらない中心点から相対に行われます。写真の左上隅を基準に拡大縮小または回転を行うと、写真の右下隅を基準にした場合とは異なる位置に移動することになります。

ScaleAndRotate コードを 図 2 に示します。拡大縮小と回転の中心点には、最初の指を使用しています。このような中心点は、PinchStarted イベントの処理中の変換で設定され、ピンチのシーケンスが行われている間は変化しません。PinchDeltaイベントの間、DistanceRatio プロパティと TotalAngleDelta プロパティにより、この中心点から相対に拡大縮小と回転の情報が返されます。その後の最初の指の移動による変化 (保存済みのフィールドで検出する必要があります) は、全体的な変換要素になります。

図 2 ScaleAndRotate コード

public partial class MainPage : PhoneApplicationPage
{
    bool isDragging;
    bool isPinching;
    Point ptPinchPositionStart;

    public MainPage()
    {
        InitializeComponent();
    }

    void OnGestureListenerDragStarted(object sender, DragStartedGestureEventArgs args)
    {
        isDragging = args.OriginalSource == image;
    }

    void OnGestureListenerDragDelta(object sender, DragDeltaGestureEventArgs args)
    {
        if (isDragging)
        {
            translateTransform.X += args.HorizontalChange;
            translateTransform.Y += args.VerticalChange;
        }
    }

    void OnGestureListenerDragCompleted(object sender, 
      DragCompletedGestureEventArgs args)
    {
        if (isDragging)
        {
            TransferTransforms();
            isDragging = false;
        }
    }

    void OnGestureListenerPinchStarted(object sender, 
      PinchStartedGestureEventArgs args)
    {
        isPinching = args.OriginalSource == image;

        if (isPinching)
        {
            // Set transform centers
            Point ptPinchCenter = args.GetPosition(image);
            ptPinchCenter = previousTransform.Transform(ptPinchCenter);

            scaleTransform.CenterX = ptPinchCenter.X;
            scaleTransform.CenterY = ptPinchCenter.Y;

            rotateTransform.CenterX = ptPinchCenter.X;
            rotateTransform.CenterY = ptPinchCenter.Y;

            ptPinchPositionStart = args.GetPosition(this);
        }
    }
    void OnGestureListenerPinchDelta(object sender, PinchGestureEventArgs args)
    {
        if (isPinching)
        {
            // Set scaling
            scaleTransform.ScaleX = args.DistanceRatio;
            scaleTransform.ScaleY = args.DistanceRatio;

            // Optionally set rotation
            if (allowRotateCheckBox.IsChecked.Value)
                rotateTransform.Angle = args.TotalAngleDelta;

            // Set translation
            Point ptPinchPosition = args.GetPosition(this);
            translateTransform.X = ptPinchPosition.X - ptPinchPositionStart.X;
            translateTransform.Y = ptPinchPosition.Y - ptPinchPositionStart.Y;
        }
    }

    void OnGestureListenerPinchCompleted(object sender, PinchGestureEventArgs args)
    {
        if (isPinching)
        {
            TransferTransforms();
            isPinching = false;
        }
    }

    void TransferTransforms()
    {
        previousTransform.Matrix = Multiply(previousTransform.Matrix, 
          currentTransform.Value);

        // Set current transforms to default values
        scaleTransform.ScaleX = scaleTransform.ScaleY = 1;
        scaleTransform.CenterX = scaleTransform.CenterY = 0;

        rotateTransform.Angle = 0;
        rotateTransform.CenterX = rotateTransform.CenterY = 0;

        translateTransform.X = translateTransform.Y = 0;
    }

    Matrix Multiply(Matrix A, Matrix B)
    {
        return new Matrix(A.M11 * B.M11 + A.M12 * B.M21,
                          A.M11 * B.M12 + A.M12 * B.M22,
                          A.M21 * B.M11 + A.M22 * B.M21,
                          A.M21 * B.M12 + A.M22 * B.M22,
                          A.OffsetX * B.M11 + A.OffsetY * B.M21 + B.OffsetX,
                          A.OffsetX * B.M12 + A.OffsetY * B.M22 + B.OffsetY);
    }
}

以上が、私が作成した簡単なピンチのコードです。私でも作成できたという事実が、この新しいジェスチャ インターフェイスの最高の裏付けとなるでしょう。

つまり、マルチタッチの統一理論は、手の届かない存在ではないかもしれません。

Charles Petzold は MSDN マガジンの記事を長期にわたって担当している寄稿編集者です。新しく執筆した『Programming Windows Phone 7』(Microsoft Press、2010 年) は、bit.ly/cpebookpdf (英語) から無料でダウンロードできます。

この記事のレビューに協力してくれた技術スタッフの Richard Bailey に心より感謝いたします。