WPF4 ベータ2 におけるイナーシアについて

今回は、WPF4ベータ2のイナーシア(慣性)を説明します。イナーシアは慣性という日本語訳をしますが、なぜ慣性というかといえば、タッチした動作に基づいて運動量の強弱を表現するからです。Windows SDK に記載されている運動量のグラフが以下になります。
Inertia

このグラフをみると、Velocityが時間の経過とともに減衰するのがわかります。このような時間経過に伴う運動量の増減を処理するために、Win32 API では InertiaProcessor が用意されています。 イナーシアの動作を理解するためには、以下のような動きを実現することだと理解すれば良いでしょう。
InertiaBehavior
この図に表現しているボーダーやエラスティックマージン(跳ね返る領域を定義するマージン)とは、Win32APIで定義されているものになります。

WPF4ではManipulation と Inertia が統合されており、Inertiaを実現するには以下のようなXAMLを記述します。

 <Window x:Class="Inertia.MainWindow"
        xmlns="https://schemas.microsoft.com/winfx/.../presentation"
        xmlns:x="https://schemas.microsoft.com/.../xaml"
        Title="イナーシア サンプル" Height="500" Width="600"
        WindowStartupLocation="CenterScreen" WindowState="Normal"
        >
    <Canvas x:Name="canvas"
            ManipulationStarting="canvas_ManipulationStarting"
            ManipulationDelta="canvas_ManipulationDelta"
            ManipulationInertiaStarting="canvas_ManipulationInertiaStarting">
        <Image IsManipulationEnabled="True" x:Name="image1"
                Width="200" Source="Guitar.jpg">
            <Image.RenderTransform>
                <MatrixTransform />
            </Image.RenderTransform>
        </Image>
    </Canvas>
</Window>

上記のXAMLで理解できますが、Manipulationの時に記述したイベントハンドラであるManipulationStartingとManipulationDeltaに続いて、ManipulationInertiaStartingイベントハンドラを追加しています。このManipulationInertiaStarting イベントハンドラで慣性の動きである運動量の減衰値を設定します。このイベントハンドラの具体例を説明する前に、WPFにおけるイベントの発生順序を以下に示します。
ManipulationEvents

上記の上側に表現しているTouchDown、TouchMove、TouchUpは、生のタッチイベントになります。タッチイベントが発生してから、Manipulationに関係するイベントであるManipulationStartingやManipulationDeltaが発生して、タッチアップかManipulationDeltaイベントからManipulationInertiaStartingイベントが発生し、Inertiaに対応するManipulationDeltaイベントが発生することを表しています。そしてInertiaの終了時にCompletedイベントが発生します。このようにイベントが発生するため、ManipulationDeltaEventArgsに IsInertiaプロパティが用意されています。

MSDNライブラリに掲載されているウォークスルーを使って、イベントハンドラのコードを以下に示します。

 private void canvas_ManipulationStarting(object sender,
                                  ManipulationStartingEventArgs e)
{
    // コンテナを設定します
    e.ManipulationContainer = this;
    e.Handled = true;
}

private void canvas_ManipulationDelta(object sender,
                                  ManipulationDeltaEventArgs e)
{
    // 操作対象のUI要素を取得
    var element = e.OriginalSource as UIElement;
    // 移動させるために MatrixTransformを取得
    var transform = element.RenderTransform as MatrixTransform;
    // 取得したMatrixTransformよりMatrixを取得します
    // (移動させるためのMatrixTransformを作成するためです)
    var matrix = transform == null ?
                 Matrix.Identity : transform.Matrix;
    // 移動量を設定します
    // (ScaleAt:ズーム、RotateAt:回転、TranslateAt:移動)
    matrix.ScaleAt(e.DeltaManipulation.Scale.X,
                   e.DeltaManipulation.Scale.Y,
                   e.ManipulationOrigin.X,
                   e.ManipulationOrigin.Y);
    matrix.RotateAt(e.DeltaManipulation.Rotation,
                    e.ManipulationOrigin.X,
                    e.ManipulationOrigin.Y);
    matrix.Translate(e.DeltaManipulation.Translation.X,
                     e.DeltaManipulation.Translation.Y);
    // 移動します
    element.RenderTransform = new MatrixTransform(matrix);

    // マニピュレーションコンテナの四角形を取得します
    Rect containingRect =
        new Rect(((FrameworkElement)
                   e.ManipulationContainer).RenderSize);
    // 移動後のUI要素の軸並行境界を含む四角形を取得します
    Rect shapeBounds =
        element.RenderTransform.TransformBounds(
            new Rect(element.RenderSize));

    // マニピュレーションコンテナの境界を越えたら
    // イナーシアの操作を停止します
    if (e.IsInertial && 
                     !containingRect.Contains(shapeBounds))
    {
        e.Complete();
        e.ReportBoundaryFeedback(e.DeltaManipulation);
    }
}

void canvas_ManipulationInertiaStarting(
            object sender, ManipulationInertiaStartingEventArgs e)
{

    // 移動における減速度 
    // 10 インチ/秒 
    // (10 インチ * 96 ピクセル/インチ / 1000ミリ秒^2)
    e.TranslationBehavior.DesiredDeceleration =
                         10.0 * 96.0 / (1000.0 * 1000.0);

    // ズームにおける減速度 
    // 0.1 インチ/秒
    // (0.1 インチ * 96 ピクセル/インチ / (1000ミリ秒^2)
    e.ExpansionBehavior.DesiredDeceleration =
                       0.1 * 96 / 1000.0 * 1000.0;

    // ローテートにおける減速度 
    // 2 回転/秒
    // (2 * 360 回転 / (1000ミリ秒^2)
    e.RotationBehavior.DesiredDeceleration =
                      720 / (1000.0 * 1000.0);

    e.Handled = true;
}

このロジックの中で、ManipulationStartingイベントハンドラはマニピュレーションで説明したものと同じになります。ManipulationDeltaイベントハンドラも操作する個所は同じですが、最後に特別なコードが追加されています。そして、ManipulationInertiaStartingイベントハンドラがマニピュレーションに対して増えています。マニピュレーションと違う点を以下にまとめます。

  • ManipulationDelta:
    コンテナの領域を取得して、移動後のUI要素が領域外に出た場合にイナーシアを終了します。このイナーシアを終了するために、Completeメソッドを呼び出しています。ReportBoundaryFeedbackメソッドは、この時点ではオマジナイと思っていただければ結構です。
  • ManipulationInertiaStarting:
    移動やズーム、ローテートにおける運動量の減速度を設定しています。

実際にビルドして動かしてみるとわかりますが、四隅の領域に達すると動きが停止します。この停止が、CompleteメソッドとReportBoundaryFeedbackメソッドを記述している効果によるものです。でも、早く動かしたりするとイメージが領域外に出てしまうこともあります。これを冒頭に説明したエラスティックマージンやボーダーを使って、跳ね返らせるのは次回にでも説明します。ここでは、運動の減衰量を設定しているManipulationInertiaStartingEventArgsの設定値を説明します。

振る舞い プロパティ 単位
TranslateBehavior InitialVelocity DesiredDeceleration DesiredDisplacement 1/96 DIP/ミリ秒 1/96 DIP/ミリ秒の二乗 1/96 DIP
RotationBehavior InitialVelocity DesiredDeceleration DesiredRotation 角度/ミリ秒 角度/ミリ秒の二乗 角度
ExpansionBehavior InitialVelocity InitialRadius DesiredDeceleration DesiredEcpansion 1/96 DIP/ミリ秒 1/96 DIP 1/96 DIP/ミリ秒の二乗 1/96 DIP

上記の表のDIPという単位は、デバイス非依存ピクセル(Device Independ Pixel)と呼ばれている単位で、1インチ当たり 1/96 ピクセルという仮想ピクセルになります(この仮想ピクセルは、Vista以降に導入されたと私は記憶しています。間違っていたら、ご容赦ください)。表の中のプロパティには、排他的にしか設定できないものも含まれています。具体的には、以下のプロパティはどちらかだけを使用します。

  • Deceleration:減衰
  • Displacement:移動量(ベクトル)

この振る舞いの値を適切に設定することで自然な動作を作りこむことが可能になります。このプロパティの値を最適化するのは、なかなか難しいというのが私の感想です。なぜなら、タッチする速度は人によって様々ですし、個々人の個性も出るからです。この当たりは、実際に作成して色々な方たちからのフィードバックを収集して、適切な値にチューニングすべきではないかと私は考えています。

追記:イナーシアの振る舞いの規定値は、0になっています。よってWPF4でイナーシアを実現する場合は、振る舞いを必ず設定する必要があります。