触摸操作

使用矩阵转换实现触摸拖动、捏合和旋转

在移动设备等多点触控环境中,用户经常使用手指来操作屏幕上的对象。 单指拖动和双指捏合等常见手势可以移动和缩放对象,甚至还可以旋转对象。 系统通常使用转换矩阵来实现这些手势,本文将介绍此操作的实现方法。

经过平移、缩放和旋转的位图

此处显示的所有示例均使用从效果调用事件一文中介绍的 Xamarin.Forms 触控跟踪效果。

拖动和转换

矩阵转换最重要的应用程序之一是触摸处理。 单个 SKMatrix 值可以合并一系列触摸操作。

对于单指拖动,SKMatrix 值负责执行转换。 位图拖动页面中对此有所演示。 XAML 文件会实例化 Xamarin.FormsGrid 中的 SKCanvasView。 已将 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 集合中,但这并不适用于所有平台。 由于在此配置中,SKCanvasView 的大小与 Grid 相同,因此将前者附加到 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.FormsPoint 值转换为 SkiaSharp SKPoint 值。 这是一个基于 SKCanvasViewWidthHeight 属性(与设备无关的单位)和 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 将失效。

试用该程序时,请注意,只有当手指触摸显示位图的区域时才能拖动位图。 尽管此限制对于此程序并不重要,但在操作多个位图时,这一点至关重要。

捏合和缩放

当使用两个手指触摸位图时,你想要系统执行什么操作? 如果两个手指平行移动,那么你可能希望位图随着手指移动。 如果两个手指执行捏合或拉伸操作,那么你可能希望旋转(将在下一节中讨论)或缩放位图。 缩放位图时,使两个手指相对于位图保持在相同位置,并相应地缩放位图,这一做法很有意义。

同时处理两个手指似乎很复杂,但请记住,TouchAction 处理程序一次仅接收有关一根手指的信息。 如果正在使用两根手指操纵位图,则对于每个事件,假设一根手指已更改位置,但另一根手指未更改。 在下面的位图缩放页面代码中,未改变位置的手指称为轴心点,因为变换是相对于该点的。

该程序与前一程序的区别之一是必须保存多个触摸 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;
        }
    }
    ···
}

除了将 ID 和触摸点添加到字典中之外,对 Pressed 操作的处理与前面的程序几乎相同。 ReleasedCancelled 操作会移除字典条目。

然而,Moved 操作处理起来会更加复杂。 如果只涉及一根手指,则处理过程与上一个程序非常相似。 如果使用两根或更多根手指,程序还必须从包含未移动手指的字典中获取信息。 程序通过将字典键复制到数组中,然后将第一个键与正在移动的手指的 ID 进行比较来实现此目的。 这样,程序就可以获得与不移动的手指相对应的轴心点。

接下来,程序会计算两个向量:新手指相对于轴心点的位置和旧手指相对于轴心点的位置。 这些向量的比例是缩放因子。 由于有可能被零除,因此必须检查这些值是否为无限值或 NaN(非数值)值。 如果一切正常,缩放转换将与保存为字段的 SKMatrix 值连接起来。

根据此页面尝试操作时,你会发现可以用一根或两根手指拖动位图,或用两根手指实现缩放。 缩放具有各向异性,这意味着水平和垂直方向的缩放可以不同。 这会扭曲纵横比,但你也可以借此翻转位图以制作镜像。 你可能还会发现,可以将位图缩小到零维,然后它就会消失。 在生产代码中,需要防范这种情况。

双指旋转

位图旋转页支持使用两根手指进行旋转或各向同性缩放。 位图始终保持其正确的宽高比。 使用两个手指进行旋转和各向异性缩放效果不佳,因为对这两个任务而言,手指的移动非常相似。

此程序的第一个大区别是命中测试逻辑。 之前的程序使用 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 的两个向量。 然后确定这些向量的角度,差值就是旋转角度。

在这一计算过程中,还可能涉及缩放操作,因此系统会根据旋转角度旋转旧向量。 现在,两个向量的相对大小是缩放因子。 请注意,水平和垂直缩放使用相同的 scale 值,因此缩放是各向同性的。 matrix 字段由旋转矩阵和缩放矩阵调整。

如果应用程序需要对单个位图(或其他对象)实现触摸处理,你可以根据自己的应用程序,改编这三个示例中的代码。 但如果要对多个位图实现触摸处理,则可能需要将这些触摸操作封装在其他类中。

封装触摸操作

触摸操作页面演示了对单个位图的触摸操作,但使用了其他几个文件,这些文件封装了上面显示的大部分逻辑。 这些文件中的第一个是 TouchManipulationMode 枚举,该文件会指示你将看到的代码实现的不同类型的触摸操作:

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

PanOnly 是使用转换实现的单根手指拖动。 所有后续选项也包括平移,但需用到两根手指:IsotropicScale 是捏合操作,可令对象在水平和垂直方向上等比例缩放。 AnisotropicScale 支持不等比例的缩放。

ScaleRotate 选项适用于双指缩放和旋转。 缩放是各向同性的。 如前所述,通过各向异性缩放实现两指旋转存在问题,因为手指移动基本上是相同的。

ScaleDualRotate 选项会添加单指旋转。 当单根手指拖动对象时,被拖动的对象首先绕其中心旋转,使得对象的中心与拖动矢量对齐。

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>

底部是一个 SKCanvasViewTouchEffect,二者均位于单单元格 Grid 的结构中。

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 对象的 Mode 属性的 TouchManager 属性设置为 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 处理程序调用 TouchManipulationBitmap 中名为 HitTestProcessTouchEvent 的两个方法:

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 还会调用 TouchManipulationBitmap 中的 ProcessTouchEvent 类。 这是一些(但不是全部)实际触摸处理发生的地方。

TouchManipulationBitmap 类是 SKBitmap 的包装类,其中包含渲染位图和处理触摸事件的代码。 我们会将其与 TouchManipulationManager 类中更通用的代码结合使用(会在稍后介绍)。

TouchManipulationBitmap 构造函数保存 SKBitmap 并实例化两个属性,即 TouchManipulationManager 类型的 TouchManager 属性和 SKMatrix 类型的 Matrix 属性:

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 属性是所有触摸活动生成的累积转换。 如你所见,系统会将每个触摸事件解析为矩阵,然后将其与 SKMatrix 属性存储的 Matrix 值连接起来。

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。 这使用了前面在“位图旋转”页面中显示的逻辑:

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 中的第二个公共方法为 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;
        }
    }
    ...
}

MovedReleased 事件中,该方法调用 Manipulate。 在这些时候,touchDictionary 会包含一个或多个 TouchManipulationInfo 对象。 如果 touchDictionary 包含一个项目,则 PreviousPointNewPoint 值很可能不相等并且表示手指的移动。 如果使用多根手指触摸位图,则字典包含多个项目,但其中只有一项具有不同的 PreviousPointNewPoint 值。 所有其余的都有相同的 PreviousPointNewPoint 值。

这一点很重要:Manipulate 方法可以假定它只处理一根手指的移动。 在此调用期间,其他手指都不会移动,如果它们真的移动(可能),系统将在后续对 Manipulate 的调用中处理这些移动。

为了方便起见,Manipulate 方法会先将字典复制到数组。 它会忽略前两个条目以外的任何内容。 如果使用不止两根手指尝试操作位图,则其他手指将被忽略。 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;
    }
}

如果一根手指正在操纵位图,Manipulate 会调用 OneFingerManipulate 对象的 TouchManipulationManager 方法。 如果两根手指正在操作位图,它调用 TwoFingerManipulate。 这些方法的参数是相同的:prevPointnewPoint 参数表示正在移动的手指。 但是这两个调用的 pivotPoint 参数不同:

对于单指操作,pivotPoint 是位图的中心。 这可用于实现单指旋转。 对于双指操作,该事件指示仅一根手指的移动,因此 pivotPoint 是未移动的手指。

在这两种情况下,TouchManipulationManager 会返回一个 SKMatrix 值,该方法将其与 Matrix 用于呈现位图的当前 TouchManipulationPage 属性连接起来。

已对 TouchManipulationManager 执行通用化处理,除了 TouchManipulationMode 之外不使用其他文件。 你可以在自己的应用程序中使用此类而无需进行任何更改。 它定义类型为 TouchManipulationMode 的单一属性:

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

但是,你可能希望避免使用 AnisotropicScale 选项。 使用此选项会很容易让缩放因子之一变为零。 这会令位图从视线中消失,无法再复现。 如果你确实需要各向异性缩放,则需要完善逻辑以避免出现不良结果。

TouchManipulationManager 使用矢量,但由于 SkiaSharp 中没有 SKVector 结构,因此改用 SKPointSKPoint 支持减法运算符,结果可视为向量。 唯一需要添加的特定于向量的逻辑是 Magnitude 计算:

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

每当选择旋转时,单指和双指操作方法都会首先处理旋转。 如果系统检测到任何旋转,则会有效地移除旋转组件。 余下内容会被解释为平移和缩放。

下面是 OneFingerManipulate 方法。 如果尚未启用单指旋转,则逻辑很简单 - 它只需使用前一个点和新点来构造一个名为 delta 的向量,该向量与平移精确对应。 启用单指旋转后,该方法使用从轴心点(位图中心)到前一个点和新点的角度来构造旋转矩阵:

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 方法中,轴心点是手指的位置,该手指不会在此特定触摸事件中移动。 旋转与单指旋转非常相似,然后调整名为 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;
    }
    ...
}

如你所见,此方法中没有显式转换。 但是,MakeRotationMakeScale 方法都以轴心点为参考,并且包含隐式转换。 如果你在位图上使用两根手指并向同一方向拖动它们,则 TouchManipulation 将在两根手指之间交替获得一系列触摸事件。 当每根手指相对于另一根手指移动时,即会出现缩放或旋转,但它会被另一根手指的移动所抵消,结果是平移。

触摸操作页面唯一剩余的部分TouchManipulationPage 代码隐藏文件中的 PaintSurface 处理程序。 这将调用 PaintTouchManipulationBitmap 方法,该方法应用表示累积触摸活动的矩阵:

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 对象,该对象显示累积的触摸矩阵:

“触摸操作”页的三重屏幕截图

操作多个位图

TouchManipulationBitmapTouchManipulationManager 等类中隔离触摸处理代码的优点之一是能够在允许用户操作多个位图的程序中重用这些类。

位图散点视图页面演示了如何完成此操作。 BitmapScatterPage 类定义了一个位图对象 List,而不是定义 TouchManipulationBitmap 类型的字段:

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 逻辑会将位图移动到集合的末尾,以便在视觉效果上将其移动到其他位图堆的顶部。

MovedReleased 事件中,TouchAction 处理程序会调用 TouchManipulationBitmap 中的 ProcessingTouchEvent 方法,就像之前的程序一样。

最后,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);
        }
    }
}

该代码会循环遍历集合,并显示集合开头到末尾的位图堆:

“位图散点视图”页的三重屏幕截图

单指缩放

缩放操作通常需要使用两根手指作出捏合手势。 然而,可以通过用单个手指移动位图的角来实现缩放。

单指角标缩放页面中对此有所演示。 由于此示例使用的缩放类型与 TouchManipulationManager 类中实现的缩放类型略有不同,因此它不使用该类或 TouchManipulationBitmap 类。 相反,所有触摸逻辑都位于代码隐藏文件中。 这一逻辑比常规逻辑更简单一些,因为它一次仅跟踪一根手指,并且简单地忽略可能触摸屏幕的任何辅助手指。

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 操作类型,处理程序检查是否没有其他手指正在触摸屏幕,并且手指是否位于位图边界内。

代码的关键部分是 if 语句,涉及对 Math.Pow 方法的两次调用。 此数学检查手指位置是否位于填充位图的椭圆之外。 如果位于之外,那这就是一个缩放操作。 手指位于位图的一个角附近,并且确定了一个对角的轴心点。 如果手指位于此椭圆内,则它是常规平移操作:

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 操作类型计算从手指按下屏幕到此时的触摸活动对应的矩阵。 它会将该矩阵与手指第一次按下位图时有效的矩阵连接起来。 缩放操作始终相对于与手指触摸的角相对的角。

对于小型或长方形位图,内部椭圆可能会占据位图的大部分,并在角落处留下微小区域以缩放位图。 你可能更喜欢使用某种不同的方法,在这种情况下,可以使用以下代码替换将 isScaling 设置为 true 的整个 if 块:

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);
    }
}

此代码会有效地将位图区域划分为内部菱形和角上的四个三角形。 这样,拐角处的面积就大得多,可便于抓取和缩放位图。