Compartir a través de


Manipulaciones táctiles

Usar transformaciones de matriz para implementar el arrastre táctil, el pinzamiento y la rotación

En entornos multitáctil, como los de dispositivos móviles, los usuarios suelen usar sus dedos para manipular objetos en la pantalla. Los gestos comunes, como un arrastre de un dedo y pellizcar con dos dedos, pueden mover y escalar objetos, o incluso girarlos. Estos gestos se implementan generalmente mediante matrices de transformación y en este artículo se muestra cómo hacerlo.

Un mapa de bits sometido a traslación, escalado y rotación

Todos los ejemplos que se muestran aquí usan el efecto de seguimiento táctil Xamarin.Forms presentado en el artículo Invocación de eventos de efectos.

Arrastrar y traducir

Una de las aplicaciones más importantes de las transformaciones de matriz es el procesamiento táctil. Un único valor SKMatrix puede consolidar una serie de operaciones táctiles.

Para arrastrar un solo dedo, el valor SKMatrix realiza la traducción. Esto se muestra en la página Arrastrar mapa de bits. El archivo XAML crea una instancia SKCanvasView en un GridXamarin.Forms. Se ha agregado un objeto TouchEffect a la colección Effects de ese Grid:

<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>

En teoría, el objeto TouchEffect se podría agregar directamente a la colección Effects de SKCanvasView, pero eso no funciona en todas las plataformas. Dado que SKCanvasView es del mismo tamaño que Grid en esta configuración, adjuntarlo a Grid funciona igual de bien.

El archivo de código subyacente se carga en un recurso de mapa de bits en su constructor y lo muestra en el controlador 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());
    }
}

Sin ningún código adicional, el valor SKMatrix siempre es la matriz de identificación y no tendría ningún efecto en la presentación del mapa de bits. El objetivo del controlador OnTouchEffectAction establecido en el archivo XAML es modificar el valor de matriz para reflejar las manipulaciones táctiles.

El controlador OnTouchEffectAction comienza convirtiendo el valor Xamarin.FormsPoint en un valor SKPoint SkiaSharp. Se trata de una cuestión sencilla de escalado en función de las propiedades Width y Height de SKCanvasView (que son unidades independientes del dispositivo) y la propiedad CanvasSize, que se encuentra en unidades de píxeles:

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

Cuando un dedo toca por primera vez la pantalla, se desencadena un evento de tipo TouchActionType.Pressed. La primera tarea es determinar si el dedo está tocando el mapa de bits. Esta tarea suele denominar prueba de posicionamiento. En este caso, las pruebas de posicionamiento se pueden realizar creando un valor SKRect correspondiente al mapa de bits, aplicando la transformación de matriz a ella con MapRect y, a continuación, determinar si el punto táctil está dentro del rectángulo transformado.

Si es así, el campo touchId se establece en el identificador táctil y se guarda la posición del dedo.

Para el evento TouchActionType.Moved, los factores de traducción del valor SKMatrix se ajustan en función de la posición actual del dedo y la nueva posición del dedo. Esa nueva posición se guarda para la próxima vez y SKCanvasView se invalida.

A medida que experimente con este programa, tome nota de que solo puede arrastrar el mapa de bits cuando el dedo toca un área donde se muestra el mapa de bits. Aunque esa restricción no es muy importante para este programa, se convierte en crucial al manipular varios mapas de bits.

Reducir y escalar

¿Qué quieres pasar cuando dos dedos tocan el mapa de bits? Si los dos dedos se mueven en paralelo, es probable que desee que el mapa de bits se mueva junto con los dedos. Si los dos dedos realizan una operación de pellizcar o estirar, es posible que desee que el mapa de bits se gire (que se describirá en la sección siguiente) o se escale. Al escalar un mapa de bits, tiene más sentido que los dos dedos permanezcan en las mismas posiciones relativas al mapa de bits y que el mapa de bits se escale en consecuencia.

El control de dos dedos a la vez parece complicado, pero tenga en cuenta que el controlador TouchAction solo recibe información sobre un dedo a la vez. Si dos dedos manipulan el mapa de bits, para cada evento, un dedo ha cambiado de posición pero el otro no ha cambiado. En el siguiente código de página Escalado de mapa de bits, el dedo que no ha cambiado de posición se denomina punto de pivote porque la transformación es relativa a ese punto.

Una diferencia entre este programa y el programa anterior es que se deben guardar varios identificadores táctiles. Un diccionario se usa para este propósito, donde el identificador táctil es la clave del diccionario y el valor del diccionario es la posición actual de ese dedo:

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

El control de la acción Pressed es casi el mismo que el programa anterior, excepto que el identificador y el punto táctil se agregan al diccionario. Las acciones Released y Cancelled quitan la entrada del diccionario.

Sin embargo, el control de la acción Moved es más complejo. Si solo hay un dedo implicado, el procesamiento es muy igual que el programa anterior. Para dos o más dedos, el programa también debe obtener información del diccionario que implica el dedo que no se mueve. Para ello, copia las claves de diccionario en una matriz y, a continuación, compara la primera clave con el identificador del dedo que se mueve. Esto permite al programa obtener el punto de pivote correspondiente al dedo que no se mueve.

A continuación, el programa calcula dos vectores de la nueva posición del dedo en relación con el punto de pivote y la posición del dedo anterior en relación con el punto de pivote. Las relaciones de estos vectores son factores de escalado. Dado que la división por cero es una posibilidad, se deben comprobar si hay valores infinitos o valores NaN (no es un número). Si todo está bien, una transformación de escalado se concatena con el valor SKMatrix guardado como un campo.

A medida que experimente con esta página, observará que puede arrastrar el mapa de bits con uno o dos dedos, o escalarlo con dos dedos. El escalado es anisotrópico, lo que significa que el escalado puede ser diferente en las direcciones horizontales y verticales. Esto distorsiona la relación de aspecto, pero también permite voltear el mapa de bits para crear una imagen reflejada. También puede detectar que puede reducir el mapa de bits a una dimensión cero y desaparece. En el código de producción, querrá protegerse contra esto.

Rotación de dos dedos

La página Rotación de mapa de bits le permite usar dos dedos para el escalado de giro o isotrópico. El mapa de bits siempre conserva su relación de aspecto correcta. El uso de dos dedos para la rotación y el escalado anisotrópico no funciona muy bien porque el movimiento de los dedos es muy similar para ambas tareas.

La primera gran diferencia en este programa es la lógica de pruebas de posicionamiento. Los programas anteriores usaron el método Contains de SKRect para determinar si el punto táctil está dentro del rectángulo transformado que corresponde al mapa de bits. Pero a medida que el usuario manipula el mapa de bits, el mapa de bits puede girarse y SKRect no puede representar correctamente un rectángulo girado. Es posible que tenga miedo de que la lógica de pruebas de posicionamiento necesite implementar geometría analítica bastante compleja en ese caso.

Sin embargo, hay disponible un acceso directo: determinar si un punto se encuentra dentro de los límites de un rectángulo transformado es el mismo que determinar si un punto transformado inverso se encuentra dentro de los límites del rectángulo no transformado. Este es un cálculo mucho más sencillo y la lógica puede seguir usando el método práctico 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));
    }
    ···
}

La lógica del evento Moved se inicia como el programa anterior. Dos vectores denominados oldVector y newVector se calculan en función del punto anterior y actual del dedo móvil y el punto dinámico del dedo inmóvil. Sin embargo, se determinan los ángulos de estos vectores y la diferencia es el ángulo de rotación.

El escalado también puede estar implicado, por lo que el vector antiguo se gira en función del ángulo de rotación. La magnitud relativa de los dos vectores es ahora el factor de escalado. Observe que se usa el mismo valor scale para el escalado horizontal y vertical para que el escalado sea isotrópico. El campo matrix se ajusta mediante la matriz de rotación y una matriz de escala.

Si la aplicación necesita implementar el procesamiento táctil para un solo mapa de bits (u otro objeto), puede adaptar el código de estos tres ejemplos para su propia aplicación. Pero si necesita implementar el procesamiento táctil para varios mapas de bits, probablemente querrá encapsular estas operaciones táctiles en otras clases.

Encapsular las operaciones táctiles

La página Manipulación táctil muestra la manipulación táctil de un solo mapa de bits, pero el uso de otros archivos que encapsulan gran parte de la lógica mostrada anteriormente. El primero de estos archivos es la enumeración TouchManipulationMode, que indica los distintos tipos de manipulación táctil implementados por el código que verá:

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

PanOnly es un arrastre de un dedo que se implementa con traducción. Todas las opciones posteriores también incluyen movimiento panorámico, pero implican dos dedos: IsotropicScale es una operación de reducción que da como resultado el escalado de objetos igualmente en las direcciones horizontales y verticales. AnisotropicScale permite un escalado desigual.

La opción ScaleRotate es para el escalado y la rotación de dos dedos. El escalado es isotrópico. Como se mencionó anteriormente, la implementación de la rotación de dos dedos con escala anisotrópico es problemática porque los movimientos de los dedos son esencialmente los mismos.

La opción ScaleDualRotate agrega rotación de un dedo. Cuando un solo dedo arrastra el objeto, el objeto arrastrado se gira por primera vez alrededor de su centro para que el centro del objeto se alinea con el vector de arrastre.

El archivo TouchManipulationPage.xaml incluye un Picker con los miembros de la enumeración TouchManipulationMode:

<?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>

Hacia la parte inferior es una SKCanvasView y una TouchEffect adjunta a la celda única Grid que la incluye.

El archivo de código subyacente TouchManipulationPage.xaml.cs tiene un campo bitmap, pero no es de tipo SKBitmap. El tipo es TouchManipulationBitmap (una clase que verá en breve):

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

El constructor crea una instancia de un objeto TouchManipulationBitmap y pasa al constructor un SKBitmap obtenido de un recurso incrustado. El constructor concluye estableciendo la propiedad Mode de la propiedad TouchManager del objeto TouchManipulationBitmap en un miembro de la enumeración TouchManipulationMode.

El controlador SelectedIndexChanged de Picker también establece esta propiedad 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;
        }
    }
    ...
}

El controlador TouchAction de la instancia de TouchEffect en el archivo XAML llama a dos métodos en TouchManipulationBitmap denominados HitTest y ProcessTouchEvent:

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

Si el método HitTest devuelve true, lo que significa que un dedo ha tocado la pantalla dentro del área ocupada por el mapa de bits, el identificador táctil se agrega a la colección TouchIds. Este identificador representa la secuencia de eventos táctiles de ese dedo hasta que el dedo se eleva desde la pantalla. Si varios dedos tocan el mapa de bits, la colección touchIds contiene un identificador táctil para cada dedo.

El controlador TouchAction también llama a la clase ProcessTouchEvent en TouchManipulationBitmap. Aquí es donde se produce una parte (no toda) del procesamiento táctil real.

La clase TouchManipulationBitmap es una clase contenedora para SKBitmap que contiene código para representar el mapa de bits y procesar eventos táctiles. Funciona junto con código más generalizado en una clase TouchManipulationManager (que verá en breve).

El constructor TouchManipulationBitmap guarda SKBitmap y crea una instancia de dos propiedades, la propiedad TouchManager de tipo TouchManipulationManager y la propiedad Matrix de tipoSKMatrix:

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

Esta propiedad Matrix es la transformación acumulada resultante de toda la actividad táctil. Como verá, cada evento táctil se resuelve en una matriz, que se concatena con el valor SKMatrix almacenado por la propiedad Matrix.

El objeto TouchManipulationBitmap se dibuja en su método Paint. El argumento es un objeto SKCanvas. Este SKCanvas podría tener ya aplicada una transformación, por lo que el método Paint concatena la propiedad Matrix asociada con el mapa de bits a la transformación existente y restaura el lienzo cuando haya terminado:

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

El método HitTest devuelve true si el usuario toca la pantalla en un punto dentro de los límites del mapa de bits. Esto usa la lógica mostrada anteriormente en la página Rotación de mapa de bits:

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

El segundo método público de TouchManipulationBitmap es ProcessTouchEvent. Cuando se llama a este método, ya se ha establecido que el evento táctil pertenece a este mapa de bits determinado. El método mantiene un diccionario de objetos TouchManipulationInfo, que es simplemente el punto anterior y el nuevo punto de cada dedo:

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

    public SKPoint NewPoint { set; get; }
}

Este es el diccionario y el propio método 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;
        }
    }
    ...
}

En los eventos Moved y Released, el método llama a Manipulate. En estos momentos, touchDictionary contiene uno o varios objetos TouchManipulationInfo. Si touchDictionary contiene un elemento, es probable que los valores PreviousPoint y NewPoint sean distintos y representen el movimiento de un dedo. Si varios dedos tocan el mapa de bits, el diccionario contiene más de un elemento, pero solo uno de estos elementos tiene valores PreviousPoint y NewPoint diferentes. Todos los demás tienen valores PreviousPoint y NewPoint iguales.

Esto es importante: el método Manipulate puede suponer que está procesando el movimiento de un solo dedo. En el momento de esta llamada ninguno de los otros dedos se mueve y si realmente se mueven (como es probable), esos movimientos se procesarán en futuras llamadas a Manipulate.

El método Manipulate copia primero el diccionario en una matriz para mayor comodidad. Omite todo lo que no sea las dos primeras entradas. Si hay más de dos dedos intentando manipular el mapa de bits, se omiten los demás. Manipulate es el miembro final de TouchManipulationBitmap:

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

Si un dedo está manipulando el mapa de bits, Manipulate llama al método OneFingerManipulate del objeto TouchManipulationManager. Para dos dedos, llama a TwoFingerManipulate. Los argumentos de estos métodos son los mismos: los argumentos prevPoint y newPoint representan el dedo que se mueve. Pero el argumento pivotPoint es diferente para las dos llamadas:

Para la manipulación con un dedo, pivotPoint es el centro del mapa de bits. Esto es para permitir la rotación de un dedo. Para la manipulación con dos dedos, el evento indica el movimiento de un solo dedo, de modo que pivotPoint es el dedo que no se mueve.

En ambos casos, TouchManipulationManager devuelve un valor SKMatrix, que el método concatena con la propiedad Matrix actual que TouchManipulationPage usa para representar el mapa de bits.

TouchManipulationManager se generaliza y no usa ningún otro archivo excepto TouchManipulationMode. Es posible que pueda usar esta clase sin cambios en sus propias aplicaciones. Define una única propiedad de tipo TouchManipulationMode:

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

Sin embargo, probablemente quiera evitar la opción AnisotropicScale. Es muy fácil con esta opción manipular el mapa de bits para que uno de los factores de escalado se convierta en cero. Eso hace que el mapa de bits desaparezca de la vista, para nunca volver. Si realmente necesita escalado anisotrópico, querrá mejorar la lógica para evitar resultados no deseados.

TouchManipulationManager hace uso de vectores, pero como no hay ninguna estructura SKVector en SkiaSharp, SKPoint se usa en su lugar. SKPoint admite el operador de resta y el resultado se puede tratar como un vector. La única lógica específica del vector que se debe agregar es un cálculo Magnitude:

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

Cada vez que se haya seleccionado la rotación, los métodos de manipulación con un dedo y dos dedos controlan primero la rotación. Si se detecta algún giro, el componente de rotación se quita eficazmente. Lo que permanece se interpreta como movimiento panorámico y escalado.

Este es el método OneFingerManipulate. Si no se ha habilitado la rotación de un dedo, la lógica es sencilla, simplemente usa el punto anterior y el nuevo punto para construir un vector denominado delta que corresponde precisamente a la traducción. Con la rotación de un dedo habilitada, el método usa ángulos desde el punto dinámico (el centro del mapa de bits) hasta el punto anterior y el nuevo punto para construir una matriz de rotación:

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

En el método TwoFingerManipulate, el punto dinámico es la posición del dedo que no se mueve en este evento táctil concreto. La rotación es muy similar a la rotación de un dedo y, a continuación, el vector denominado oldVector (basado en el punto anterior) se ajusta para la rotación. El movimiento restante se interpreta como escalado:

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

Observará que no hay ninguna traducción explícita en este método. Sin embargo, tanto los métodos MakeRotation como MakeScale se basan en el punto dinámico, y eso incluye la traducción implícita. Si usa dos dedos en el mapa de bits y los arrastra en la misma dirección, TouchManipulation obtendrá una serie de eventos táctiles que alternan entre los dos dedos. Cuando cada dedo se mueve en relación con el otro, se produce una escala o rotación, pero el movimiento del otro dedo la anula y el resultado es una traslación.

La única parte restante de la página Manipulación táctil es el controlador PaintSurface en el archivo de código subyacente TouchManipulationPage. Esto llama al método Paint de TouchManipulationBitmap, que aplica la matriz que representa la actividad táctil acumulada:

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

El controlador PaintSurface concluye mostrando un objeto MatrixDisplay que muestra la matriz táctil acumulada:

Captura de pantalla triple de la página Manipulación táctil

Manipulación de varios mapas de bits

Una de las ventajas de aislar el código de procesamiento táctil en clases como TouchManipulationBitmap y TouchManipulationManager es la capacidad de reutilizar estas clases en un programa que permite al usuario manipular varios mapas de bits.

La página Vista de dispersión de mapa de bits muestra cómo se hace. En lugar de definir un campo de tipo TouchManipulationBitmap, la clase BitmapScatterPage define un List de objetos de mapa de bits:

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

El constructor se carga en todos los mapas de bits disponibles como recursos incrustados y los agrega a bitmapCollection. Observe que la propiedad Matrix se inicializa en cada objeto TouchManipulationBitmap, por lo que las esquinas superior izquierda de cada mapa de bits se desplazan 100 píxeles.

La página BitmapScatterView también debe controlar eventos táctiles para varios mapas de bits. En lugar de definir un List de identificadores táctiles de TouchManipulationBitmap manipulados actualmente, este programa requiere un diccionario:

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

Observe cómo la lógica Pressed recorre la bitmapCollection en sentido inverso. Los mapas de bits a menudo se superponen entre sí. Los mapas de bits más adelante en la colección se encuentran visualmente encima de los mapas de bits anteriores en la colección. Si hay varios mapas de bits bajo el dedo que presionan en la pantalla, el más alto debe ser el que manipula ese dedo.

Observe también que la lógica Pressed mueve ese mapa de bits al final de la colección para que se mueva visualmente a la parte superior de la pila de otros mapas de bits.

En los eventos Moved y Released, el controlador TouchAction llama al método ProcessingTouchEvent en TouchManipulationBitmap igual que el programa anterior.

Por último, el controlador PaintSurface llama al método Paint de cada objeto TouchManipulationBitmap:

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

El código recorre la colección y muestra la pila de mapas de bits desde el principio de la colección hasta el final:

Captura de pantalla triple de la página Vista de dispersión de mapa de bits

Escalado de un solo dedo

Por lo general, una operación de escalado requiere un gesto de reducción mediante dos dedos. Sin embargo, es posible implementar el escalado con un solo dedo haciendo que el dedo mueva las esquinas de un mapa de bits.

Esto se muestra en la página Escala de esquina de un solo dedo. Dado que en este ejemplo se usa un tipo de escalado ligeramente diferente al implementado en la clase TouchManipulationManager, no usa esa clase ni la clase TouchManipulationBitmap. En su lugar, toda la lógica táctil está en el archivo de código subyacente. Esta es una lógica algo más sencilla de lo habitual porque realiza un seguimiento de un solo dedo a la vez y simplemente omite los dedos secundarios que podrían estar tocando la pantalla.

La página SingleFingerCornerScale.xaml crea una instancia de la clase SKCanvasView y crea un objeto TouchEffect para realizar el seguimiento de eventos táctiles:

<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>

El archivo SingleFingerCornerScalePage.xaml.cs carga un recurso de mapa de bits desde el directorio Media y lo muestra mediante un objeto SKMatrix definido como campo:

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

Este objeto SKMatrix se modifica mediante la lógica táctil que se muestra a continuación.

El resto del archivo de código subyacente es el controlador TouchEffect de eventos. Comienza convirtiendo la ubicación actual del dedo en un valor SKPoint. Para el tipo de acción Pressed, el controlador comprueba que ningún otro dedo está tocando la pantalla y que el dedo está dentro de los límites del mapa de bits.

La parte fundamental del código es una instrucción if que implica dos llamadas al método Math.Pow. Esta matemática comprueba si la ubicación del dedo está fuera de una elipse que rellena el mapa de bits. Si es así, se trata de una operación de escalado. El dedo está cerca de una de las esquinas del mapa de bits y se determina un punto dinámico que es la esquina opuesta. Si el dedo está dentro de esta elipse, es una operación de movimiento panorámico normal:

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

El tipo de acción Moved calcula una matriz correspondiente a la actividad táctil desde el momento en que el dedo presiona la pantalla hasta esta vez. Concatena esa matriz con la matriz en vigor en el momento en que el dedo presiona por primera vez el mapa de bits. La operación de escalado siempre es relativa a la esquina opuesta a la que tocó el dedo.

Para mapas de bits pequeños o grandes, una elipse interior podría ocupar la mayoría del mapa de bits y dejar áreas pequeñas en las esquinas para escalar el mapa de bits. Es posible que prefiera un enfoque algo diferente, en cuyo caso puede reemplazar ese bloque if completo que establece isScaling en true por este código:

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

Este código divide eficazmente el área del mapa de bits en una forma de diamante interior y cuatro triángulos en las esquinas. Esto permite áreas mucho más grandes en las esquinas para agarrar y escalar el mapa de bits.