タッチ操作

マトリックス変換を使用してタッチによるドラッグ、ピンチ、回転を実装する

モバイル デバイスなどのマルチタッチ環境では、ユーザーはしばしば指を使って画面上のオブジェクトを操作します。 1 本指でのドラッグや 2 本指のピンチなどの一般的な動作で、オブジェクトの移動や拡大縮小、さらに回転も実行できます。 これらの動作は通常、変換マトリックスを使用して実装されます。この記事では、その方法について説明します。

平行移動、拡大縮小、回転の対象となるビットマップ

ここに示すサンプルはすべて、「エフェクトからのイベントの呼び出し」記事に示されている Xamarin.Forms のタッチ トラッキング エフェクトを使用します。

ドラッグと変換

マトリックス変換の最も重要なアプリケーションの 1 つは、タッチ処理です。 1 つの SKMatrix 値が、一連のタッチ操作を統合できます。

1 本指でのドラッグの場合、SKMatrix 値が変換を実行します。 これを [Bitmap Dragging] ページに示します。 XAML ファイルが、SKCanvasView を Xamarin.Forms の Grid 内でインスタンス化します。 TouchEffect オブジェクトがその GridEffects コレクションに追加されました。

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
             xmlns:tt="clr-namespace:TouchTracking"
             x:Class="SkiaSharpFormsDemos.Transforms.BitmapDraggingPage"
             Title="Bitmap Dragging">
    
    <Grid BackgroundColor="White">
        <skia:SKCanvasView x:Name="canvasView"
                           PaintSurface="OnCanvasViewPaintSurface" />
        <Grid.Effects>
            <tt:TouchEffect Capture="True"
                            TouchAction="OnTouchEffectAction" />
        </Grid.Effects>
    </Grid>
</ContentPage>

理論上は、TouchEffect オブジェクトを SKCanvasViewEffects コレクションに直接追加できますが、これはすべてのプラットフォームで機能するわけではありません。 この構成では、SKCanvasViewGrid と同じサイズであるため、Grid にアタッチしても問題なく機能します。

分離コード ファイルが、ビットマップ リソースをそのコンストラクター内に読み込み、PaintSurface ハンドラーに表示します。

public partial class BitmapDraggingPage : ContentPage
{
    // Bitmap and matrix for display
    SKBitmap bitmap;
    SKMatrix matrix = SKMatrix.MakeIdentity();
    ···

    public BitmapDraggingPage()
    {
        InitializeComponent();

        string resourceID = "SkiaSharpFormsDemos.Media.SeatedMonkey.jpg";
        Assembly assembly = GetType().GetTypeInfo().Assembly;

        using (Stream stream = assembly.GetManifestResourceStream(resourceID))
        {
            bitmap = SKBitmap.Decode(stream);
        }
    }
    ···
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        // Display the bitmap
        canvas.SetMatrix(matrix);
        canvas.DrawBitmap(bitmap, new SKPoint());
    }
}

SKMatrix 値は、それ以上のコードなしで常にマトリックスを識別し、ビットマップの表示には影響を及ぼしません。 XAML ファイル内に設定される OnTouchEffectAction ハンドラーの目標は、タッチ操作を反映するようにマトリックス値を変更することです。

OnTouchEffectAction ハンドラーはまず、Xamarin.Forms の Point 値を SkiaSharp の SKPoint 値に変換します。 これは、SKCanvasViewWidth プロパティおよび Height プロパティ (デバイスに依存しない単位) と、CanvasSize プロパティ (ピクセル単位) に基づく単純な拡大縮小の問題です。

public partial class BitmapDraggingPage : ContentPage
{
    ···
    // Touch information
    long touchId = -1;
    SKPoint previousPoint;
    ···
    void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        // Convert Xamarin.Forms point to pixels
        Point pt = args.Location;
        SKPoint point = 
            new SKPoint((float)(canvasView.CanvasSize.Width * pt.X / canvasView.Width),
                        (float)(canvasView.CanvasSize.Height * pt.Y / canvasView.Height));

        switch (args.Type)
        {
            case TouchActionType.Pressed:
                // Find transformed bitmap rectangle
                SKRect rect = new SKRect(0, 0, bitmap.Width, bitmap.Height);
                rect = matrix.MapRect(rect);

                // Determine if the touch was within that rectangle
                if (rect.Contains(point))
                {
                    touchId = args.Id;
                    previousPoint = point;
                }
                break;

            case TouchActionType.Moved:
                if (touchId == args.Id)
                {
                    // Adjust the matrix for the new position
                    matrix.TransX += point.X - previousPoint.X;
                    matrix.TransY += point.Y - previousPoint.Y;
                    previousPoint = point;
                    canvasView.InvalidateSurface();
                }
                break;

            case TouchActionType.Released:
            case TouchActionType.Cancelled:
                touchId = -1;
                break;
        }
    }
    ···
}

指が最初に画面に触れたときに、型 TouchActionType.Pressed のイベントが発生します。 最初のタスクは、指がビットマップに触れているかどうかを判断することです。 このようなタスクはしばしば、"ヒット テスト" と呼ばれます。 このケースでは、ヒット テストは、ビットマップに対応する SKRect 値を作成し、MapRect を使用してそれにマトリックス変換を適用してから、変換された四角形内にタッチ ポイントがあるかどうかを判断することで、実現できます。

あった場合は、touchId フィールドがそのタッチ ID に設定され、指の位置が保存されます。

TouchActionType.Moved イベントの場合、指の現在位置と新しい位置に基づいて、SKMatrix 値の変換係数が調整されます。 その新しい位置が次回まで保存され、SKCanvasView が無効になります。

このプログラムを試しながら、ビットマップが表示されている領域に指が触れたときにのみビットマップをドラッグできることに注目してください。 この制約は、このプログラムにとってはあまり重要ではありませんが、複数のビットマップを操作するときには重要になります。

ピンチと拡大縮小

2 本の指でビットマップに触れるときは何が目的でしょうか。 2 本の指を並列に移動する場合は、おそらくビットマップを指に合わせて移動しようとしているでしょう。 2 本の指でピンチ操作または拡張操作を実行する場合は、ビットマップの回転 (次のセクションで説明します) か、拡大縮小を実行しようとしているでしょう。 ビットマップを拡大縮小するときは、2 本の指のビットマップに対する相対位置を同じままにし、それに応じてビットマップを拡大縮小させるのが最も合理的です。

2 本の指を同時に処理するのは複雑なようですが、TouchAction ハンドラーは一時点では 1 本の指に関する情報のみを受け取ることを念頭に置いてください。 2 本の指がビットマップを操作していても、それぞれのイベントについては、1 本の指の位置が変更され、もう一方は変更されません。 以下の [Bitmap Scaling] ページのコードでは、位置が変更されていない指は "ピボット" ポイントと呼ばれます。変換がそのポイントに対して相対的に行われるためです。

このプログラムと前のプログラムの違いの 1 つは、複数のタッチ ID を保存する必要があることです。 この目的でディクショナリが使用されます。タッチ ID がディクショナリ キーであり、ディクショナリ値がその指の現在の位置です。

public partial class BitmapScalingPage : ContentPage
{
    ···
    // Touch information
    Dictionary<long, SKPoint> touchDictionary = new Dictionary<long, SKPoint>();
    ···
    void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        // Convert Xamarin.Forms point to pixels
        Point pt = args.Location;
        SKPoint point =
            new SKPoint((float)(canvasView.CanvasSize.Width * pt.X / canvasView.Width),
                        (float)(canvasView.CanvasSize.Height * pt.Y / canvasView.Height));

        switch (args.Type)
        {
            case TouchActionType.Pressed:
                // Find transformed bitmap rectangle
                SKRect rect = new SKRect(0, 0, bitmap.Width, bitmap.Height);
                rect = matrix.MapRect(rect);

                // Determine if the touch was within that rectangle
                if (rect.Contains(point) && !touchDictionary.ContainsKey(args.Id))
                {
                    touchDictionary.Add(args.Id, point);
                }
                break;

            case TouchActionType.Moved:
                if (touchDictionary.ContainsKey(args.Id))
                {
                    // Single-finger drag
                    if (touchDictionary.Count == 1)
                    {
                        SKPoint prevPoint = touchDictionary[args.Id];

                        // Adjust the matrix for the new position
                        matrix.TransX += point.X - prevPoint.X;
                        matrix.TransY += point.Y - prevPoint.Y;
                        canvasView.InvalidateSurface();
                    }
                    // Double-finger scale and drag
                    else if (touchDictionary.Count >= 2)
                    {
                        // Copy two dictionary keys into array
                        long[] keys = new long[touchDictionary.Count];
                        touchDictionary.Keys.CopyTo(keys, 0);

                        // Find index of non-moving (pivot) finger
                        int pivotIndex = (keys[0] == args.Id) ? 1 : 0;

                        // Get the three points involved in the transform
                        SKPoint pivotPoint = touchDictionary[keys[pivotIndex]];
                        SKPoint prevPoint = touchDictionary[args.Id];
                        SKPoint newPoint = point;

                        // Calculate two vectors
                        SKPoint oldVector = prevPoint - pivotPoint;
                        SKPoint newVector = newPoint - pivotPoint;

                        // Scaling factors are ratios of those
                        float scaleX = newVector.X / oldVector.X;
                        float scaleY = newVector.Y / oldVector.Y;

                        if (!float.IsNaN(scaleX) && !float.IsInfinity(scaleX) &&
                            !float.IsNaN(scaleY) && !float.IsInfinity(scaleY))
                        {
                            // If something bad hasn't happened, calculate a scale and translation matrix
                            SKMatrix scaleMatrix = 
                                SKMatrix.MakeScale(scaleX, scaleY, pivotPoint.X, pivotPoint.Y);

                            SKMatrix.PostConcat(ref matrix, scaleMatrix);
                            canvasView.InvalidateSurface();
                        }
                    }

                    // Store the new point in the dictionary
                    touchDictionary[args.Id] = point;
                }

                break;

            case TouchActionType.Released:
            case TouchActionType.Cancelled:
                if (touchDictionary.ContainsKey(args.Id))
                {
                    touchDictionary.Remove(args.Id);
                }
                break;
        }
    }
    ···
}

Pressed アクションの処理は、ID とタッチ ポイントがディクショナリに追加される点を除いて、前のプログラムとほぼ同じです。 Released アクションと Cancelled アクションは、ディクショナリ エントリを削除します。

ただし、Moved アクションの処理はより複雑です。 関与する指が 1 本だけの場合、処理は前のプログラムとほぼ同じです。 2 本以上の指の場合、プログラムは、移動していない指に関する情報をディクショナリから取得することも必要になります。 これを行うには、ディクショナリ キーを配列にコピーしてから、最初のキーと、移動した指の ID とを比較します。 これで、移動していない指に対応するピボット ポイントをプログラムが取得できます。

次に、ピボット ポイントに対する新しい指の位置と、ピボットポイントに対する古い指の位置の 2 つのベクターをプログラムが計算します。 これらのベクターの比率が、拡大縮小係数です。 0 で除算する可能性があるため、無限値や NaN (数値ではない) 値がないか、これらを検査する必要があります。 すべて問題ない場合は、拡大縮小変換が、フィールドとして保存された SKMatrix 値に連結されます。

このページを試しながら、1 本または 2 本の指でのビットマップのドラッグや、2 本の指での拡大縮小を実行できることを確認します。 拡大縮小は "異方的" です。つまり、拡大縮小は水平方向と垂直方向で異なる可能性があります。 これにより、縦横比が歪みますが、ビットマップを反転して鏡像を作成することもできます。 ビットマップを 0 次元に縮小できることに気づかれるかもしれません。そうすると非表示になります。 運用環境のコードでは、こうならないように保護する必要があります。

2 本指での回転

[Bitmap Rotate] ページでは、2 本の指を使用して、回転または等方的な拡大縮小を行うことができます。 ビットマップは常に正しい縦横比を維持します。 回転と異方的な拡大縮小の両方に 2 本の指を使用しても、両方のタスクで指の動きが非常に似ているため、あまりうまく機能しません。

このプログラムの最初の大きな違いは、ヒット テストのロジックです。 前のプログラムでは、SKRectContains メソッドを使用して、タッチ ポイントがビットマップに対応する変換された四角形内にあるかどうかを判断しました。 しかし、ユーザーがビットマップを操作すると、ビットマップが回転するため、SKRect が回転した四角形を適切に表すことができない可能性があります。 その場合、ヒット テストのロジックが、やや複雑な分析ジオメトリを実装する必要があることを懸念されるかもしれません。

ただし、抜け道があります。あるポイントが変換された四角形の境界内にあるかどうかを判断することは、逆変換されたポイントが変換されていない四角形の境界内にあるかどうかを判断するのと同じです。 これははるかに簡単な計算であり、ロジックは引き続き便利な Contains メソッドを使用できます。

public partial class BitmapRotationPage : ContentPage
{
    ···
    // Touch information
    Dictionary<long, SKPoint> touchDictionary = new Dictionary<long, SKPoint>();
    ···
    void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        // Convert Xamarin.Forms point to pixels
        Point pt = args.Location;
        SKPoint point =
            new SKPoint((float)(canvasView.CanvasSize.Width * pt.X / canvasView.Width),
                        (float)(canvasView.CanvasSize.Height * pt.Y / canvasView.Height));

        switch (args.Type)
        {
            case TouchActionType.Pressed:
                if (!touchDictionary.ContainsKey(args.Id))
                {
                    // Invert the matrix
                    if (matrix.TryInvert(out SKMatrix inverseMatrix))
                    {
                        // Transform the point using the inverted matrix
                        SKPoint transformedPoint = inverseMatrix.MapPoint(point);

                        // Check if it's in the untransformed bitmap rectangle
                        SKRect rect = new SKRect(0, 0, bitmap.Width, bitmap.Height);

                        if (rect.Contains(transformedPoint))
                        {
                            touchDictionary.Add(args.Id, point);
                        }
                    }
                }
                break;

            case TouchActionType.Moved:
                if (touchDictionary.ContainsKey(args.Id))
                {
                    // Single-finger drag
                    if (touchDictionary.Count == 1)
                    {
                        SKPoint prevPoint = touchDictionary[args.Id];

                        // Adjust the matrix for the new position
                        matrix.TransX += point.X - prevPoint.X;
                        matrix.TransY += point.Y - prevPoint.Y;
                        canvasView.InvalidateSurface();
                    }
                    // Double-finger rotate, scale, and drag
                    else if (touchDictionary.Count >= 2)
                    {
                        // Copy two dictionary keys into array
                        long[] keys = new long[touchDictionary.Count];
                        touchDictionary.Keys.CopyTo(keys, 0);

                        // Find index non-moving (pivot) finger
                        int pivotIndex = (keys[0] == args.Id) ? 1 : 0;

                        // Get the three points in the transform
                        SKPoint pivotPoint = touchDictionary[keys[pivotIndex]];
                        SKPoint prevPoint = touchDictionary[args.Id];
                        SKPoint newPoint = point;

                        // Calculate two vectors
                        SKPoint oldVector = prevPoint - pivotPoint;
                        SKPoint newVector = newPoint - pivotPoint;

                        // Find angles from pivot point to touch points
                        float oldAngle = (float)Math.Atan2(oldVector.Y, oldVector.X);
                        float newAngle = (float)Math.Atan2(newVector.Y, newVector.X);

                        // Calculate rotation matrix
                        float angle = newAngle - oldAngle;
                        SKMatrix touchMatrix = SKMatrix.MakeRotation(angle, pivotPoint.X, pivotPoint.Y);

                        // Effectively rotate the old vector
                        float magnitudeRatio = Magnitude(oldVector) / Magnitude(newVector);
                        oldVector.X = magnitudeRatio * newVector.X;
                        oldVector.Y = magnitudeRatio * newVector.Y;

                        // Isotropic scaling!
                        float scale = Magnitude(newVector) / Magnitude(oldVector);

                        if (!float.IsNaN(scale) && !float.IsInfinity(scale))
                        {
                            SKMatrix.PostConcat(ref touchMatrix,
                                SKMatrix.MakeScale(scale, scale, pivotPoint.X, pivotPoint.Y));

                            SKMatrix.PostConcat(ref matrix, touchMatrix);
                            canvasView.InvalidateSurface();
                        }
                    }

                    // Store the new point in the dictionary
                    touchDictionary[args.Id] = point;
                }

                break;

            case TouchActionType.Released:
            case TouchActionType.Cancelled:
                if (touchDictionary.ContainsKey(args.Id))
                {
                    touchDictionary.Remove(args.Id);
                }
                break;
        }
    }

    float Magnitude(SKPoint point)
    {
        return (float)Math.Sqrt(Math.Pow(point.X, 2) + Math.Pow(point.Y, 2));
    }
    ···
}

Moved イベントのロジックは、前のプログラムと同様に開始されます。 oldVectornewVector という名前の 2 つのベクターが、移動している指の前のポイントおよび現在のポイントと、移動していない指のピボット ポイントに基づいて計算されます。 しかし、次にこれらのベクターの角度が判断されます。違いは回転角度です。

拡大縮小も発生する可能性があるため、古いベクターが回転角度に基づいて回転されます。 これで、2 つのベクターの相対的な大きさが拡大縮小係数になりました。 拡大縮小が等方的になるように、水平方向と垂直方向の拡大縮小に同じ scale 値が使用されていることに注意してください。 matrix フィールドは、回転マトリックスと拡大縮小マトリックスの両方によって調整されます。

アプリケーションで 1 つのビットマップ (または他のオブジェクト) のタッチ処理を実装する必要がある場合は、これら 3 つのサンプルのコードを独自のアプリケーションに合わせて調整できます。 ただし、複数のビットマップのタッチ処理を実装する必要がある場合は、これらのタッチ操作を他のクラスにカプセル化することが考えられます。

タッチ操作のカプセル化

[Touch Manipulation] ページは、1 つのビットマップのタッチ操作を示しますが、上記のロジックの多くをカプセル化する他のいくつかのファイルを使用します。 これらのファイルの 1 番めは TouchManipulationMode 列挙です。これは、次に示すコードによって実装されるさまざまな種類のタッチ操作を示します。

enum TouchManipulationMode
{
    None,
    PanOnly,
    IsotropicScale,     // includes panning
    AnisotropicScale,   // includes panning
    ScaleRotate,        // implies isotropic scaling
    ScaleDualRotate     // adds one-finger rotation
}

PanOnly は、変換とともに実装される 1 本指のドラッグです。 それ以降のすべてのオプションにもパンが含まれますが、2 本の指が使用されます。IsotropicScale は、オブジェクトが水平方向と垂直方向に等しく拡大縮小されるピンチ操作です。 AnisotropicScale を使用すると、不均衡な拡大縮小が可能になります。

ScaleRotate オプションは、2 本指での拡大縮小と回転用です。 拡大縮小は等方的です。 前述のように、異方的な拡大縮小を行う 2 本指の回転を実装することは、指の動きが本質的に同じであるために難しくなります。

ScaleDualRotate オプションは、1 本指での回転を追加します。 1 本の指がオブジェクトをドラッグすると、ドラッグされたオブジェクトはまずその中心の周りを回転して、オブジェクトの中心がドラッグ ベクターに合うように移動します。

TouchManipulationPage.xaml ファイルには、TouchManipulationMode 列挙型のメンバーの Picker が含まれています。

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
             xmlns:tt="clr-namespace:TouchTracking"
             xmlns:local="clr-namespace:SkiaSharpFormsDemos.Transforms"
             x:Class="SkiaSharpFormsDemos.Transforms.TouchManipulationPage"
             Title="Touch Manipulation">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="*" />
        </Grid.RowDefinitions>

        <Picker Title="Touch Mode"
                Grid.Row="0"
                SelectedIndexChanged="OnTouchModePickerSelectedIndexChanged">
            <Picker.ItemsSource>
                <x:Array Type="{x:Type local:TouchManipulationMode}">
                    <x:Static Member="local:TouchManipulationMode.None" />
                    <x:Static Member="local:TouchManipulationMode.PanOnly" />
                    <x:Static Member="local:TouchManipulationMode.IsotropicScale" />
                    <x:Static Member="local:TouchManipulationMode.AnisotropicScale" />
                    <x:Static Member="local:TouchManipulationMode.ScaleRotate" />
                    <x:Static Member="local:TouchManipulationMode.ScaleDualRotate" />
                </x:Array>
            </Picker.ItemsSource>
            <Picker.SelectedIndex>
                4
            </Picker.SelectedIndex>
        </Picker>
        
        <Grid BackgroundColor="White"
              Grid.Row="1">
            
            <skia:SKCanvasView x:Name="canvasView"
                               PaintSurface="OnCanvasViewPaintSurface" />
            <Grid.Effects>
                <tt:TouchEffect Capture="True"
                                TouchAction="OnTouchEffectAction" />
            </Grid.Effects>
        </Grid>
    </Grid>
</ContentPage>

終わり付近には、SKCanvasView と、それを含む単一セルの Grid にアタッチされている TouchEffect があります。

TouchManipulationPage.xaml.cs 分離コード ファイルには bitmap フィールドがありますが、型が SKBitmap ではありません。 その型は TouchManipulationBitmap (すぐ下に示すクラス) です。

public partial class TouchManipulationPage : ContentPage
{
    TouchManipulationBitmap bitmap;
    ...

    public TouchManipulationPage()
    {
        InitializeComponent();

        string resourceID = "SkiaSharpFormsDemos.Media.MountainClimbers.jpg";
        Assembly assembly = GetType().GetTypeInfo().Assembly;

        using (Stream stream = assembly.GetManifestResourceStream(resourceID))
        {
            SKBitmap bitmap = SKBitmap.Decode(stream);
            this.bitmap = new TouchManipulationBitmap(bitmap);
            this.bitmap.TouchManager.Mode = TouchManipulationMode.ScaleRotate;
        }
    }
    ...
}

コンストラクターが TouchManipulationBitmap オブジェクトをインスタンス化し、埋め込みリソースから取得した SKBitmap をコンストラクターに渡します。 コンストラクターは、TouchManipulationBitmap オブジェクトの TouchManager プロパティの Mode プロパティを TouchManipulationMode 列挙型のメンバーに設定して終了します。

PickerSelectedIndexChanged ハンドラーも、この Mode プロパティを設定します。

public partial class TouchManipulationPage : ContentPage
{
    ...
    void OnTouchModePickerSelectedIndexChanged(object sender, EventArgs args)
    {
        if (bitmap != null)
        {
            Picker picker = (Picker)sender;
            bitmap.TouchManager.Mode = (TouchManipulationMode)picker.SelectedItem;
        }
    }
    ...
}

XAML ファイルでインスタンス化された TouchEffectTouchAction ハンドラーが、TouchManipulationBitmapHitTestProcessTouchEvent という名前の 2 つのメソッドを呼び出します。

public partial class TouchManipulationPage : ContentPage
{
    ...
    List<long> touchIds = new List<long>();
    ...
    void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        // Convert Xamarin.Forms point to pixels
        Point pt = args.Location;
        SKPoint point =
            new SKPoint((float)(canvasView.CanvasSize.Width * pt.X / canvasView.Width),
                        (float)(canvasView.CanvasSize.Height * pt.Y / canvasView.Height));

        switch (args.Type)
        {
            case TouchActionType.Pressed:
                if (bitmap.HitTest(point))
                {
                    touchIds.Add(args.Id);
                    bitmap.ProcessTouchEvent(args.Id, args.Type, point);
                    break;
                }
                break;

            case TouchActionType.Moved:
                if (touchIds.Contains(args.Id))
                {
                    bitmap.ProcessTouchEvent(args.Id, args.Type, point);
                    canvasView.InvalidateSurface();
                }
                break;

            case TouchActionType.Released:
            case TouchActionType.Cancelled:
                if (touchIds.Contains(args.Id))
                {
                    bitmap.ProcessTouchEvent(args.Id, args.Type, point);
                    touchIds.Remove(args.Id);
                    canvasView.InvalidateSurface();
                }
                break;
        }
    }
    ...
}

HitTest メソッドが true を返した (つまり、画面の、ビットマップが占有している領域内に指が触れた) 場合、タッチ ID が TouchIds コレクションに追加されます。 この ID は、指が画面から離れるまで、その指の一連のタッチ イベントを表します。 複数の指がビットマップにタッチした場合、touchIds コレクションには各指のタッチ ID が含められます。

TouchAction ハンドラーも、TouchManipulationBitmapProcessTouchEvent クラスを呼び出します。 ここで、実際のタッチ処理の一部 (すべてではない) が発生します。

TouchManipulationBitmap クラスは、ビットマップをレンダリングしてタッチ イベントを処理するコードを含む SKBitmap のラッパー クラスです。 これは、TouchManipulationManager クラス (すぐ下に示します) 内のより汎用的なコードと連携して動作します。

TouchManipulationBitmap コンストラクターが、SKBitmap を保存し、型 TouchManipulationManagerTouchManager プロパティと型 SKMatrixMatrix プロパティの 2 つのプロパティをインスタンス化します。

class TouchManipulationBitmap
{
    SKBitmap bitmap;
    ...

    public TouchManipulationBitmap(SKBitmap bitmap)
    {
        this.bitmap = bitmap;
        Matrix = SKMatrix.MakeIdentity();

        TouchManager = new TouchManipulationManager
        {
            Mode = TouchManipulationMode.ScaleRotate
        };
    }

    public TouchManipulationManager TouchManager { set; get; }

    public SKMatrix Matrix { set; get; }
    ...
}

この Matrix プロパティは、すべてのタッチ アクティビティの結果として累積された変換です。 ご覧のように、各タッチ イベントはマトリックスに解決されてから、Matrix プロパティが格納した SKMatrix 値と連結されます。

TouchManipulationBitmap オブジェクトが、その Paint メソッド内に自身を描画します。 引数は、SKCanvas オブジェクトです。 この SKCanvas には既に変換が適用されている可能性があるため、Paint メソッドはビットマップに関連付けられている Matrix プロパティを既存の変換に連結し、それが完了したらキャンバスを復元します。

class TouchManipulationBitmap
{
    ...
    public void Paint(SKCanvas canvas)
    {
        canvas.Save();
        SKMatrix matrix = Matrix;
        canvas.Concat(ref matrix);
        canvas.DrawBitmap(bitmap, 0, 0);
        canvas.Restore();
    }
    ...
}

HitTest メソッドは、ユーザーが画面でビットマップの境界内のポイントにタッチした場合、true を返します。 これは、前に [Bitmap Rotation] ページで示したロジックを使用します。

class TouchManipulationBitmap
{
    ...
    public bool HitTest(SKPoint location)
    {
        // Invert the matrix
        SKMatrix inverseMatrix;

        if (Matrix.TryInvert(out inverseMatrix))
        {
            // Transform the point using the inverted matrix
            SKPoint transformedPoint = inverseMatrix.MapPoint(location);

            // Check if it's in the untransformed bitmap rectangle
            SKRect rect = new SKRect(0, 0, bitmap.Width, bitmap.Height);
            return rect.Contains(transformedPoint);
        }
        return false;
    }
    ...
}

TouchManipulationBitmap 内の 2 番めのパブリック メソッドは ProcessTouchEvent です。 このメソッドが呼び出されるときには、タッチ イベントがこの特定のビットマップに属することが既に確定しています。 このメソッドは、TouchManipulationInfo オブジェクトのディクショナリを維持します。これは、単純に各指の前のポイントと新しいポイントです。

class TouchManipulationInfo
{
    public SKPoint PreviousPoint { set; get; }

    public SKPoint NewPoint { set; get; }
}

ディクショナリと ProcessTouchEvent メソッド自体を次に示します。

class TouchManipulationBitmap
{
    ...
    Dictionary<long, TouchManipulationInfo> touchDictionary =
        new Dictionary<long, TouchManipulationInfo>();
    ...
    public void ProcessTouchEvent(long id, TouchActionType type, SKPoint location)
    {
        switch (type)
        {
            case TouchActionType.Pressed:
                touchDictionary.Add(id, new TouchManipulationInfo
                {
                    PreviousPoint = location,
                    NewPoint = location
                });
                break;

            case TouchActionType.Moved:
                TouchManipulationInfo info = touchDictionary[id];
                info.NewPoint = location;
                Manipulate();
                info.PreviousPoint = info.NewPoint;
                break;

            case TouchActionType.Released:
                touchDictionary[id].NewPoint = location;
                Manipulate();
                touchDictionary.Remove(id);
                break;

            case TouchActionType.Cancelled:
                touchDictionary.Remove(id);
                break;
        }
    }
    ...
}

Moved イベントと Released イベントで、このメソッドが Manipulate を呼び出します。 これらの時点で、touchDictionary には 1 つ以上の TouchManipulationInfo オブジェクトが含まれています。 touchDictionary に含まれている項目が 1 つの場合は、PreviousPoint 値と NewPoint 値が等しくなく、おそらく 1 本の指の動きを表しています。 複数の指がビットマップに触れている場合、ディクショナリには複数の項目が含まれていますが、PreviousPoint 値と NewPoint 値が異なる項目は、これらのうち 1 つのみです。 残りはすべて等しい PreviousPoint 値と NewPoint 値を持ちます。

これは重要です。Manipulate メソッドは、1 本の指のみの動きを処理していると想定する可能性があります。 この呼び出しの時点で、他の指は動いていません。もし実際には動いている (あり得ることですが) 場合、それらの動きはその後の Manipulate への呼び出しで処理されます。

Manipulate メソッドは、便宜上、まずディクショナリを配列にコピーします。 これは、最初の 2 つのエントリ以外のエントリを無視します。 2 本以上の指でビットマップを操作しようとすると、他の指が無視されます。 ManipulateTouchManipulationBitmap の最後のメンバーです。

class TouchManipulationBitmap
{
    ...
    void Manipulate()
    {
        TouchManipulationInfo[] infos = new TouchManipulationInfo[touchDictionary.Count];
        touchDictionary.Values.CopyTo(infos, 0);
        SKMatrix touchMatrix = SKMatrix.MakeIdentity();

        if (infos.Length == 1)
        {
            SKPoint prevPoint = infos[0].PreviousPoint;
            SKPoint newPoint = infos[0].NewPoint;
            SKPoint pivotPoint = Matrix.MapPoint(bitmap.Width / 2, bitmap.Height / 2);

            touchMatrix = TouchManager.OneFingerManipulate(prevPoint, newPoint, pivotPoint);
        }
        else if (infos.Length >= 2)
        {
            int pivotIndex = infos[0].NewPoint == infos[0].PreviousPoint ? 0 : 1;
            SKPoint pivotPoint = infos[pivotIndex].NewPoint;
            SKPoint newPoint = infos[1 - pivotIndex].NewPoint;
            SKPoint prevPoint = infos[1 - pivotIndex].PreviousPoint;

            touchMatrix = TouchManager.TwoFingerManipulate(prevPoint, newPoint, pivotPoint);
        }

        SKMatrix matrix = Matrix;
        SKMatrix.PostConcat(ref matrix, touchMatrix);
        Matrix = matrix;
    }
}

1 本の指でビットマップを操作している場合、ManipulateTouchManipulationManager オブジェクトの OneFingerManipulate メソッドを呼び出します。 2 本の指の場合、TwoFingerManipulate を呼び出します。 これらのメソッドの prevPoint 引数と newPoint 引数は同じであり、移動中の指を表します。 しかし、この 2 つの呼び出しで pivotPoint 引数は異なります。

1 本指で操作する場合、pivotPoint はビットマップの中心になります。 これは、1 本指での回転を可能にするためです。 2 本指で操作する場合、イベントは 1 本の指の動きのみを示し、pivotPoint は動いていない指になります。

どちらの場合も TouchManipulationManager は、SKMatrix 値を返します。これをメソッドが現在の Matrix プロパティと連結して、TouchManipulationPage がビットマップのレンダリングに使用します。

TouchManipulationManager は一般化されていて、TouchManipulationMode 以外のファイルを使用しません。 独自のアプリケーションで変更を加えることなく、このクラスを使用できる場合があります。 TouchManipulationMode 型の単一プロパティが定義されます。

class TouchManipulationManager
{
    public TouchManipulationMode Mode { set; get; }
    ...
}

ただし、AnisotropicScale オプションは避けることをお勧めします。 このオプションを使用すると、ビットマップを操作して拡大縮小係数の 1 つをゼロにするのはいとも簡単です。 これにより、ビットマップが見えなくなり、元に戻ることはありません。 異方的な拡大縮小が本当に必要な場合は、望ましくない結果を避けるためにロジックを強化する必要があります。

TouchManipulationManager はベクターを使用しますが、SkiaSharp には SKVector 構造がないため、代わりに SKPoint が使用されます。 SKPoint は減算演算子をサポートし、結果をベクターとして扱うことができます。 追加する必要があるベクター固有のロジックは、Magnitude 計算だけです。

class TouchManipulationManager
{
    ...
    float Magnitude(SKPoint point)
    {
        return (float)Math.Sqrt(Math.Pow(point.X, 2) + Math.Pow(point.Y, 2));
    }
}

回転が選択されるたびに、1 本指と 2 本指の両方の操作メソッドがまず回転を処理します。 回転が検出されると、回転コンポーネントは実質的に削除されます。 残るのは、パンと拡大縮小であると解釈されます。

OneFingerManipulate メソッドを次に示します。 1 本指の回転が有効になっていない場合、ロジックは単純です。単純に前のポイントと新しいポイントを使用して、変換に正確に対応する delta という名前のベクターを構築します。 1 本指での回転を有効にすると、このメソッドはピボット ポイント (ビットマップの中心) から前のポイントと新しいポイントまでの角度を使用して回転マトリックスを構築します。

class TouchManipulationManager
{
    public TouchManipulationMode Mode { set; get; }

    public SKMatrix OneFingerManipulate(SKPoint prevPoint, SKPoint newPoint, SKPoint pivotPoint)
    {
        if (Mode == TouchManipulationMode.None)
        {
            return SKMatrix.MakeIdentity();
        }

        SKMatrix touchMatrix = SKMatrix.MakeIdentity();
        SKPoint delta = newPoint - prevPoint;

        if (Mode == TouchManipulationMode.ScaleDualRotate)  // One-finger rotation
        {
            SKPoint oldVector = prevPoint - pivotPoint;
            SKPoint newVector = newPoint - pivotPoint;

            // Avoid rotation if fingers are too close to center
            if (Magnitude(newVector) > 25 && Magnitude(oldVector) > 25)
            {
                float prevAngle = (float)Math.Atan2(oldVector.Y, oldVector.X);
                float newAngle = (float)Math.Atan2(newVector.Y, newVector.X);

                // Calculate rotation matrix
                float angle = newAngle - prevAngle;
                touchMatrix = SKMatrix.MakeRotation(angle, pivotPoint.X, pivotPoint.Y);

                // Effectively rotate the old vector
                float magnitudeRatio = Magnitude(oldVector) / Magnitude(newVector);
                oldVector.X = magnitudeRatio * newVector.X;
                oldVector.Y = magnitudeRatio * newVector.Y;

                // Recalculate delta
                delta = newVector - oldVector;
            }
        }

        // Multiply the rotation matrix by a translation matrix
        SKMatrix.PostConcat(ref touchMatrix, SKMatrix.MakeTranslation(delta.X, delta.Y));

        return touchMatrix;
    }
    ...
}

TwoFingerManipulate メソッドでは、ピボット ポイントは、この特定のタッチ イベントで動いていない指の位置です。 この回転は 1 本指の回転に非常によく似ていて、oldVector という名前のベクター (前のポイントに基づくもの) が回転に合わせて調整されます。 残りの移動は拡大縮小であると解釈されます。

class TouchManipulationManager
{
    ...
    public SKMatrix TwoFingerManipulate(SKPoint prevPoint, SKPoint newPoint, SKPoint pivotPoint)
    {
        SKMatrix touchMatrix = SKMatrix.MakeIdentity();
        SKPoint oldVector = prevPoint - pivotPoint;
        SKPoint newVector = newPoint - pivotPoint;

        if (Mode == TouchManipulationMode.ScaleRotate ||
            Mode == TouchManipulationMode.ScaleDualRotate)
        {
            // Find angles from pivot point to touch points
            float oldAngle = (float)Math.Atan2(oldVector.Y, oldVector.X);
            float newAngle = (float)Math.Atan2(newVector.Y, newVector.X);

            // Calculate rotation matrix
            float angle = newAngle - oldAngle;
            touchMatrix = SKMatrix.MakeRotation(angle, pivotPoint.X, pivotPoint.Y);

            // Effectively rotate the old vector
            float magnitudeRatio = Magnitude(oldVector) / Magnitude(newVector);
            oldVector.X = magnitudeRatio * newVector.X;
            oldVector.Y = magnitudeRatio * newVector.Y;
        }

        float scaleX = 1;
        float scaleY = 1;

        if (Mode == TouchManipulationMode.AnisotropicScale)
        {
            scaleX = newVector.X / oldVector.X;
            scaleY = newVector.Y / oldVector.Y;

        }
        else if (Mode == TouchManipulationMode.IsotropicScale ||
                 Mode == TouchManipulationMode.ScaleRotate ||
                 Mode == TouchManipulationMode.ScaleDualRotate)
        {
            scaleX = scaleY = Magnitude(newVector) / Magnitude(oldVector);
        }

        if (!float.IsNaN(scaleX) && !float.IsInfinity(scaleX) &&
            !float.IsNaN(scaleY) && !float.IsInfinity(scaleY))
        {
            SKMatrix.PostConcat(ref touchMatrix,
                SKMatrix.MakeScale(scaleX, scaleY, pivotPoint.X, pivotPoint.Y));
        }

        return touchMatrix;
    }
    ...
}

このメソッドには明示的な変換がないことがわかります。 ただし、MakeRotation メソッドと MakeScale メソッドの両方ともピボット ポイントに基づいているため、これには暗黙的な変換が含まれます。 ビットマップで 2 本の指を使用し、同じ方向にドラッグすると、TouchManipulation は、2 本の指で交互に発生する一連のタッチ イベントを取得します。 各指が他の指に対して相対的に移動すると、拡大縮小または回転が発生しますが、もう一方の指の動きによって否定され、変換が発生する結果になります。

[Touch Manipulation] ページで唯一残っているパーツは、TouchManipulationPage 分離コード ファイル内の PaintSurface ハンドラーです。 これは TouchManipulationBitmapPaint メソッドを呼び出します。これは、累積タッチ アクティビティを表すマトリックスを適用します。

public partial class TouchManipulationPage : ContentPage
{
    ...
    MatrixDisplay matrixDisplay = new MatrixDisplay();
    ...
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        // Display the bitmap
        bitmap.Paint(canvas);

        // Display the matrix in the lower-right corner
        SKSize matrixSize = matrixDisplay.Measure(bitmap.Matrix);

        matrixDisplay.Paint(canvas, bitmap.Matrix,
            new SKPoint(info.Width - matrixSize.Width,
                        info.Height - matrixSize.Height));
    }
}

PaintSurface ハンドラーは、累積されたタッチ マトリックスを示す MatrixDisplay オブジェクトを表示して終了します。

[Touch Manipulation] ページのトリプル スクリーンショット

複数のビットマップの操作

TouchManipulationBitmapTouchManipulationManager などのクラスでタッチ処理コードを分離する利点の 1 つは、プログラムでこれらのクラスを再利用できることであり、これを使用すると、ユーザーが複数のビットマップを操作できるようになります。

[Bitmap Scatter View] ページは、これを行う方法を示しています。 BitmapScatterPage クラスは、型 TouchManipulationBitmap のフィールドを定義するのではなく、ビットマップ オブジェクトの List を定義します。

public partial class BitmapScatterViewPage : ContentPage
{
    List<TouchManipulationBitmap> bitmapCollection =
        new List<TouchManipulationBitmap>();
    ...
    public BitmapScatterViewPage()
    {
        InitializeComponent();

        // Load in all the available bitmaps
        Assembly assembly = GetType().GetTypeInfo().Assembly;
        string[] resourceIDs = assembly.GetManifestResourceNames();
        SKPoint position = new SKPoint();

        foreach (string resourceID in resourceIDs)
        {
            if (resourceID.EndsWith(".png") ||
                resourceID.EndsWith(".jpg"))
            {
                using (Stream stream = assembly.GetManifestResourceStream(resourceID))
                {
                    SKBitmap bitmap = SKBitmap.Decode(stream);
                    bitmapCollection.Add(new TouchManipulationBitmap(bitmap)
                    {
                        Matrix = SKMatrix.MakeTranslation(position.X, position.Y),
                    });
                    position.X += 100;
                    position.Y += 100;
                }
            }
        }
    }
    ...
}

コンストラクターは、埋め込みリソースとして使用できるすべてのビットマップを読み込み、それらを bitmapCollection に追加します。 Matrix プロパティは TouchManipulationBitmap オブジェクトごとに初期化されるため、各ビットマップの左上隅が 100 ピクセル分オフセットされることに注意してください。

BitmapScatterView ページは、複数のビットマップのタッチ イベントも処理する必要があります。 このプログラムは、現在操作されている TouchManipulationBitmap オブジェクトのタッチ ID の List を定義するのではなく、ディクショナリを必要とします。

public partial class BitmapScatterViewPage : ContentPage
{
    ...
    Dictionary<long, TouchManipulationBitmap> bitmapDictionary =
       new Dictionary<long, TouchManipulationBitmap>();
    ...
    void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        // Convert Xamarin.Forms point to pixels
        Point pt = args.Location;
        SKPoint point =
            new SKPoint((float)(canvasView.CanvasSize.Width * pt.X / canvasView.Width),
                        (float)(canvasView.CanvasSize.Height * pt.Y / canvasView.Height));

        switch (args.Type)
        {
            case TouchActionType.Pressed:
                for (int i = bitmapCollection.Count - 1; i >= 0; i--)
                {
                    TouchManipulationBitmap bitmap = bitmapCollection[i];

                    if (bitmap.HitTest(point))
                    {
                        // Move bitmap to end of collection
                        bitmapCollection.Remove(bitmap);
                        bitmapCollection.Add(bitmap);

                        // Do the touch processing
                        bitmapDictionary.Add(args.Id, bitmap);
                        bitmap.ProcessTouchEvent(args.Id, args.Type, point);
                        canvasView.InvalidateSurface();
                        break;
                    }
                }
                break;

            case TouchActionType.Moved:
                if (bitmapDictionary.ContainsKey(args.Id))
                {
                    TouchManipulationBitmap bitmap = bitmapDictionary[args.Id];
                    bitmap.ProcessTouchEvent(args.Id, args.Type, point);
                    canvasView.InvalidateSurface();
                }
                break;

            case TouchActionType.Released:
            case TouchActionType.Cancelled:
                if (bitmapDictionary.ContainsKey(args.Id))
                {
                    TouchManipulationBitmap bitmap = bitmapDictionary[args.Id];
                    bitmap.ProcessTouchEvent(args.Id, args.Type, point);
                    bitmapDictionary.Remove(args.Id);
                    canvasView.InvalidateSurface();
                }
                break;
        }
    }
    ...
}

Pressed ロジックが bitmapCollection を逆向きにループ処理するしくみに注目してください。 ビットマップはしばしば、互いに重なり合います。 コレクションの後方にあるビットマップは、コレクションの前方にあるビットマップの上に視覚的に配置されます。 画面上で押す指の下に複数のビットマップがある場合、最上位のビットマップが、その指によって操作されるビットマップであるはずです。

また、Pressed ロジックがそのビットマップをコレクションの末尾に移動して、他のビットマップの積み重ねの頂上に視覚的に移動します。

Moved イベントと Released イベントでは、TouchAction ハンドラーが前のプログラムと同様に TouchManipulationBitmapProcessingTouchEvent メソッドを呼び出します。

最後に、PaintSurface ハンドラーが各 TouchManipulationBitmap オブジェクトの Paint メソッドを呼び出します。

public partial class BitmapScatterViewPage : ContentPage
{
    ...
    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKCanvas canvas = args.Surface.Canvas;
        canvas.Clear();

        foreach (TouchManipulationBitmap bitmap in bitmapCollection)
        {
            bitmap.Paint(canvas);
        }
    }
}

コードはコレクションをループ処理し、コレクションの先頭から末尾までのビットマップの積み重ねを表示します。

[Bitmap Scatter View] ページのトリプル スクリーンショット

1 本指での拡大縮小

拡大縮小操作には通常、2 本の指を使用したピンチ ジェスチャが必要です。 ただし、指でビットマップの角を移動することで、1 本指での拡大縮小を実装できます。

これを、[Single Finger Corner Scale] ページに示します。 このサンプルは、TouchManipulationManager クラスに実装されている拡大縮小とは多少異なる種類の拡大縮小を使用するため、そのクラスや TouchManipulationBitmap クラスは使用しません。 代わりに、すべてのタッチ ロジックが分離コード ファイルにあります。 これは、一度に 1 本の指のみを追跡し、画面に触れている可能性があるそれ以外の指を単に無視するため、通常よりもやや単純なロジックです。

SingleFingerCornerScale.xaml ページは、SKCanvasView クラスをインスタンス化し、タッチ イベントを追跡するための TouchEffect オブジェクトを作成します。

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
             xmlns:tt="clr-namespace:TouchTracking"
             x:Class="SkiaSharpFormsDemos.Transforms.SingleFingerCornerScalePage"
             Title="Single Finger Corner Scale">

    <Grid BackgroundColor="White"
          Grid.Row="1">

        <skia:SKCanvasView x:Name="canvasView"
                           PaintSurface="OnCanvasViewPaintSurface" />
        <Grid.Effects>
            <tt:TouchEffect Capture="True"
                            TouchAction="OnTouchEffectAction"   />
        </Grid.Effects>
    </Grid>
</ContentPage>

SingleFingerCornerScalePage.xaml.cs ファイルは、Media ディレクトリからビットマップ リソースを読み込み、フィールドとして定義された SKMatrix オブジェクトを使用して表示します。

public partial class SingleFingerCornerScalePage : ContentPage
{
    SKBitmap bitmap;
    SKMatrix currentMatrix = SKMatrix.MakeIdentity();
    ···

    public SingleFingerCornerScalePage()
    {
        InitializeComponent();

        string resourceID = "SkiaSharpFormsDemos.Media.SeatedMonkey.jpg";
        Assembly assembly = GetType().GetTypeInfo().Assembly;

        using (Stream stream = assembly.GetManifestResourceStream(resourceID))
        {
            bitmap = SKBitmap.Decode(stream);
        }
    }

    void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
    {
        SKImageInfo info = args.Info;
        SKSurface surface = args.Surface;
        SKCanvas canvas = surface.Canvas;

        canvas.Clear();

        canvas.SetMatrix(currentMatrix);
        canvas.DrawBitmap(bitmap, 0, 0);
    }
    ···
}

この SKMatrix オブジェクトは、以下に示すタッチ ロジックによって変更されます。

分離コード ファイルの残りの部分は TouchEffect イベント ハンドラーです。 まず、指の現在の位置を SKPoint 値に変換します。 アクションの種類が Pressed の場合、ハンドラーが、他の指が画面に触れていないことと、触れた指がビットマップの境界内にあることを検査します。

コードの重要な部分は、Math.Pow メソッドへの 2 つの呼び出しを含む if ステートメントです。 この数式は、指の位置がビットマップ内を埋める楕円の外側にあるかどうかを調べます。 ある場合、拡大縮小操作です。 指がビットマップの角の 1 つに近く、ピボット ポイントが反対の角であると判断されます。 指がこの楕円内にある場合は、通常のパン操作です。

public partial class SingleFingerCornerScalePage : ContentPage
{
    SKBitmap bitmap;
    SKMatrix currentMatrix = SKMatrix.MakeIdentity();

    // Information for translating and scaling
    long? touchId = null;
    SKPoint pressedLocation;
    SKMatrix pressedMatrix;

    // Information for scaling
    bool isScaling;
    SKPoint pivotPoint;
    ···

    void OnTouchEffectAction(object sender, TouchActionEventArgs args)
    {
        // Convert Xamarin.Forms point to pixels
        Point pt = args.Location;
        SKPoint point =
            new SKPoint((float)(canvasView.CanvasSize.Width * pt.X / canvasView.Width),
                        (float)(canvasView.CanvasSize.Height * pt.Y / canvasView.Height));

        switch (args.Type)
        {
            case TouchActionType.Pressed:
                // Track only one finger
                if (touchId.HasValue)
                    return;

                // Check if the finger is within the boundaries of the bitmap
                SKRect rect = new SKRect(0, 0, bitmap.Width, bitmap.Height);
                rect = currentMatrix.MapRect(rect);
                if (!rect.Contains(point))
                    return;

                // First assume there will be no scaling
                isScaling = false;

                // If touch is outside interior ellipse, make this a scaling operation
                if (Math.Pow((point.X - rect.MidX) / (rect.Width / 2), 2) +
                    Math.Pow((point.Y - rect.MidY) / (rect.Height / 2), 2) > 1)
                {
                    isScaling = true;
                    float xPivot = point.X < rect.MidX ? rect.Right : rect.Left;
                    float yPivot = point.Y < rect.MidY ? rect.Bottom : rect.Top;
                    pivotPoint = new SKPoint(xPivot, yPivot);
                }

                // Common for either pan or scale
                touchId = args.Id;
                pressedLocation = point;
                pressedMatrix = currentMatrix;
                break;

            case TouchActionType.Moved:
                if (!touchId.HasValue || args.Id != touchId.Value)
                    return;

                SKMatrix matrix = SKMatrix.MakeIdentity();

                // Translating
                if (!isScaling)
                {
                    SKPoint delta = point - pressedLocation;
                    matrix = SKMatrix.MakeTranslation(delta.X, delta.Y);
                }
                // Scaling
                else
                {
                    float scaleX = (point.X - pivotPoint.X) / (pressedLocation.X - pivotPoint.X);
                    float scaleY = (point.Y - pivotPoint.Y) / (pressedLocation.Y - pivotPoint.Y);
                    matrix = SKMatrix.MakeScale(scaleX, scaleY, pivotPoint.X, pivotPoint.Y);
                }

                // Concatenate the matrices
                SKMatrix.PreConcat(ref matrix, pressedMatrix);
                currentMatrix = matrix;
                canvasView.InvalidateSurface();
                break;

            case TouchActionType.Released:
            case TouchActionType.Cancelled:
                touchId = null;
                break;
        }
    }
}

アクションの種類 Moved は、指が画面を押した時点からこの時点までのタッチ アクティビティに対応するマトリックスを計算します。 そのマトリックスを、指がビットマップを最初に押した時点で有効だったマトリックスと連結します。 拡大縮小操作は常に、指が触れた角とは反対側の角に対して相対的です。

小さいビットマップまたは長いビットマップの場合、内部の楕円がビットマップの大部分を占有し、ビットマップを拡大縮小するために残される角の領域が小さくなる場合があります。 少し異なるアプローチを使用することが考えられます。その場合は、if ブロック全体を、isScalingtrue に設定するこのコードに置き換えることができます。

float halfHeight = rect.Height / 2;
float halfWidth = rect.Width / 2;

// Top half of bitmap
if (point.Y < rect.MidY)
{
    float yRelative = (point.Y - rect.Top) / halfHeight;

    // Upper-left corner
    if (point.X < rect.MidX - yRelative * halfWidth)
    {
        isScaling = true;
        pivotPoint = new SKPoint(rect.Right, rect.Bottom);
    }
    // Upper-right corner
    else if (point.X > rect.MidX + yRelative * halfWidth)
    {
        isScaling = true;
        pivotPoint = new SKPoint(rect.Left, rect.Bottom);
    }
}
// Bottom half of bitmap
else
{
    float yRelative = (point.Y - rect.MidY) / halfHeight;

    // Lower-left corner
    if (point.X < rect.Left + yRelative * halfWidth)
    {
        isScaling = true;
        pivotPoint = new SKPoint(rect.Right, rect.Top);
    }
    // Lower-right corner
    else if (point.X > rect.Right - yRelative * halfWidth)
    {
        isScaling = true;
        pivotPoint = new SKPoint(rect.Left, rect.Top);
    }
}

このコードは、ビットマップの領域を内側のひし形と、角の 4 つの三角形に実質的に分割します。 これにより、角の領域がより大きくなるため、それに触れてビットマップを拡大縮小できます。