Compartir a través de


Animación de los mapas de bits de SkiaSharp

Las aplicaciones que animan los gráficos SkiaSharp suelen llamar a InvalidateSurface en SKCanvasView con una velocidad fija, a menudo cada 16 milisegundos. Al invalidar la superficie, se desencadena una llamada al controlador de PaintSurface para volver a dibujar la pantalla. A medida que los objetos visuales se vuelven a dibujar 60 veces por segundo, parecen animarse sin problemas.

Sin embargo, si los gráficos son demasiado complejos para representarse en 16 milisegundos, la animación puede convertirse en vibración. El programador puede optar por reducir la frecuencia de actualización a 30 veces o 15 veces por segundo, pero a veces incluso eso no es suficiente. A veces, los gráficos son tan complejos que simplemente no se pueden representar en tiempo real.

Una solución consiste en preparar la animación de antemano mediante la representación de los fotogramas individuales de la animación en una serie de mapas de bits. Para mostrar la animación, solo es necesario mostrar estos mapas de bits secuencialmente 60 veces por segundo.

Por supuesto, eso es potencialmente una gran cantidad de mapas de bits, pero así funcionan las películas animadas en 3D de gran presupuesto. Los gráficos 3D son demasiado complejos para representarse en tiempo real. Se requiere mucho tiempo de procesamiento para representar cada fotograma. Lo que ves en una película es esencialmente una serie de mapas de bits.

Puedes hacer algo similar en SkiaSharp. En este artículo se muestran dos tipos de animación de mapa de bits. El primer ejemplo es una animación del conjunto de Mandelbrot:

Ejemplo de animación

En el segundo ejemplo se muestra cómo usar SkiaSharp para representar un archivo GIF animado.

Animación de mapa de bits

El conjunto Mandelbrot es visualmente fascinante, pero en términos de computación es muy largo. (Para obtener una explicación del conjunto de Mandelbrot y las matemáticas que se usan en él, vea el Capítulo 20 de Creación de aplicaciones móviles con Xamarin.Forms, que comienza en la página 666. En la descripción siguiente se asume que el lector tiene conocimientos de este dominio).

El ejemplo usa la animación de mapa de bits para simular un zoom continuo de un punto fijo en el conjunto Mandelbrot. Primero se hace un zoom para acercarse y luego otro para alejarse, y este ciclo se repite en un bucle infinito o hasta que termine el programa.

Para esta animación, el programa prepara hasta 50 mapas de bits que guarda en el almacenamiento local de la aplicación. Cada mapa de bits abarca la mitad del ancho y alto del plano complejo que el mapa de bits anterior. (En el programa, se dice que estos mapas de bits representan niveles de zoom integrales). A continuación, los mapas de bits se muestran en secuencia. El escalado de cada mapa de bits se anima para proporcionar una progresión suave de un mapa de bits a otro.

Al igual que el programa final descrito en el capítulo 20 de Creación de aplicaciones móviles con Xamarin.Forms, el cálculo del conjunto de Mandelbrot en Animación de Mandelbrot es un método asincrónico con ocho parámetros. Los parámetros incluyen un punto central complejo y un ancho y alto del plano complejo que rodea ese punto central. Los tres parámetros siguientes son el ancho y alto de píxeles del mapa de bits que se va a crear y un número máximo de iteraciones para el cálculo recursivo. El parámetro progress se usa para mostrar el progreso de este cálculo. El parámetro cancelToken no se usa en este programa:

static class Mandelbrot
{
    public static Task<BitmapInfo> CalculateAsync(Complex center,
                                                  double width, double height,
                                                  int pixelWidth, int pixelHeight,
                                                  int iterations,
                                                  IProgress<double> progress,
                                                  CancellationToken cancelToken)
    {
        return Task.Run(() =>
        {
            int[] iterationCounts = new int[pixelWidth * pixelHeight];
            int index = 0;

            for (int row = 0; row < pixelHeight; row++)
            {
                progress.Report((double)row / pixelHeight);
                cancelToken.ThrowIfCancellationRequested();

                double y = center.Imaginary + height / 2 - row * height / pixelHeight;

                for (int col = 0; col < pixelWidth; col++)
                {
                    double x = center.Real - width / 2 + col * width / pixelWidth;
                    Complex c = new Complex(x, y);

                    if ((c - new Complex(-1, 0)).Magnitude < 1.0 / 4)
                    {
                        iterationCounts[index++] = -1;
                    }
                    // http://www.reenigne.org/blog/algorithm-for-mandelbrot-cardioid/
                    else if (c.Magnitude * c.Magnitude * (8 * c.Magnitude * c.Magnitude - 3) < 3.0 / 32 - c.Real)
                    {
                        iterationCounts[index++] = -1;
                    }
                    else
                    {
                        Complex z = 0;
                        int iteration = 0;

                        do
                        {
                            z = z * z + c;
                            iteration++;
                        }
                        while (iteration < iterations && z.Magnitude < 2);

                        if (iteration == iterations)
                        {
                            iterationCounts[index++] = -1;
                        }
                        else
                        {
                            iterationCounts[index++] = iteration;
                        }
                    }
                }
            }
            return new BitmapInfo(pixelWidth, pixelHeight, iterationCounts);
        }, cancelToken);
    }
}

El método devuelve un objeto de tipo BitmapInfo que proporciona información para crear un mapa de bits:

class BitmapInfo
{
    public BitmapInfo(int pixelWidth, int pixelHeight, int[] iterationCounts)
    {
        PixelWidth = pixelWidth;
        PixelHeight = pixelHeight;
        IterationCounts = iterationCounts;
    }

    public int PixelWidth { private set; get; }

    public int PixelHeight { private set; get; }

    public int[] IterationCounts { private set; get; }
}

El archivo XAML de animación de Mandelbrot incluye dos vistas Label, un ProgressBar y un Button, así como:SKCanvasView

<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"
             x:Class="MandelAnima.MainPage"
             Title="Mandelbrot Animation">

    <StackLayout>
        <Label x:Name="statusLabel"
               HorizontalTextAlignment="Center" />
        <ProgressBar x:Name="progressBar" />

        <skia:SKCanvasView x:Name="canvasView"
                           VerticalOptions="FillAndExpand"
                           PaintSurface="OnCanvasViewPaintSurface" />

        <StackLayout Orientation="Horizontal"
                     Padding="5">
            <Label x:Name="storageLabel"
                   VerticalOptions="Center" />

            <Button x:Name="deleteButton"
                    Text="Delete All"
                    HorizontalOptions="EndAndExpand"
                    Clicked="OnDeleteButtonClicked" />
        </StackLayout>
    </StackLayout>
</ContentPage>

En el comienzo del archivo de código subyacente se definen tres constantes cruciales y una matriz de mapas de bits:

public partial class MainPage : ContentPage
{
    const int COUNT = 10;           // The number of bitmaps in the animation.
                                    // This can go up to 50!

    const int BITMAP_SIZE = 1000;   // Program uses square bitmaps exclusively

    // Uncomment just one of these, or define your own
    static readonly Complex center = new Complex(-1.17651152924355, 0.298520986549558);
    //   static readonly Complex center = new Complex(-0.774693089457127, 0.124226621261617);
    //   static readonly Complex center = new Complex(-0.556624880053304, 0.634696788141351);

    SKBitmap[] bitmaps = new SKBitmap[COUNT];   // array of bitmaps
    ···
}

En algún momento, probablemente querrá cambiar el valor COUNT a 50 para ver el intervalo completo de la animación. Los valores superiores a 50 no son útiles. Alrededor de un nivel de zoom de 48 o así, la resolución de números de punto flotante de doble precisión no es suficiente para el cálculo del conjunto de Mandelbrot. Este problema se describe en la página 684 de Creación de aplicaciones móviles con Xamarin.Forms.

El valor center es muy importante. Este es el foco del zoom de animación. Los tres valores del archivo son los que se usan en las tres capturas de pantalla finales del capítulo 20 de Creación de aplicaciones móviles con Xamarin.Forms en la página 684, pero puede experimentar con el programa en ese capítulo para presentar uno de sus propios valores.

El ejemplo de animación de Mandelbrot almacena estos mapas de bits COUNT en el almacenamiento de aplicaciones local. Cincuenta mapas de bits requieren más de 20 megabytes de almacenamiento en el dispositivo, por lo que es posible que quiera saber cuánto almacenamiento ocupan estos mapas de bits y, en algún momento, es posible que desee eliminarlos todos. Este es el propósito de estos dos métodos en la parte inferior de la clase MainPage:

public partial class MainPage : ContentPage
{
    ···
    void TallyBitmapSizes()
    {
        long fileSize = 0;

        foreach (string filename in Directory.EnumerateFiles(FolderPath()))
        {
            fileSize += new FileInfo(filename).Length;
        }

        storageLabel.Text = $"Total storage: {fileSize:N0} bytes";
    }

    void OnDeleteButtonClicked(object sender, EventArgs args)
    {
        foreach (string filepath in Directory.EnumerateFiles(FolderPath()))
        {
            File.Delete(filepath);
        }

        TallyBitmapSizes();
    }
}

Puede eliminar los mapas de bits en el almacenamiento local mientras el programa anima esos mismos mapas de bits porque el programa los conserva en la memoria. Pero la próxima vez que ejecute el programa, tendrá que volver a crear los mapas de bits.

Los mapas de bits almacenados en el almacenamiento de aplicaciones locales incorporan el valor center en sus nombres de archivo, por lo que si cambia la configuración center, los mapas de bits existentes no se reemplazarán en el almacenamiento y seguirán ocupando espacio.

Estos son los métodos que MainPage usa para construir los nombres de archivo, así como un método MakePixel para definir un valor de píxel basado en componentes de color:

public partial class MainPage : ContentPage
{
    ···
    // File path for storing each bitmap in local storage
    string FolderPath() =>
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);

    string FilePath(int zoomLevel) =>
        Path.Combine(FolderPath(),
                     String.Format("R{0}I{1}Z{2:D2}.png", center.Real, center.Imaginary, zoomLevel));

    // Form bitmap pixel for Rgba8888 format
    uint MakePixel(byte alpha, byte red, byte green, byte blue) =>
        (uint)((alpha << 24) | (blue << 16) | (green << 8) | red);
    ···
}

El parámetro zoomLevel de FilePath va a oscilar entre 0 y la constante COUNT menos 1.

Este constructor MainPage llama al método LoadAndStartAnimation:

public partial class MainPage : ContentPage
{
    ···
    public MainPage()
    {
        InitializeComponent();

        LoadAndStartAnimation();
    }
    ···
}

El método LoadAndStartAnimation es responsable de acceder al almacenamiento local de la aplicación para cargar los mapas de bits que se podrían haber creado cuando el programa se ejecutó anteriormente. Recorre los valores de zoomLevel en un bucle de 0 a COUNT. Si el archivo existe, lo carga en la matriz bitmaps. De lo contrario, debe crear un mapa de bits para los valores de center y zoomLevel determinados llamando a Mandelbrot.CalculateAsync. Ese método obtiene los recuentos de iteración de cada píxel, que el método convierte en colores:

public partial class MainPage : ContentPage
{
    ···
    async void LoadAndStartAnimation()
    {
        // Show total bitmap storage
        TallyBitmapSizes();

        // Create progressReporter for async operation
        Progress<double> progressReporter =
            new Progress<double>((double progress) => progressBar.Progress = progress);

        // Create (unused) CancellationTokenSource for async operation
        CancellationTokenSource cancelTokenSource = new CancellationTokenSource();

        // Loop through all the zoom levels
        for (int zoomLevel = 0; zoomLevel < COUNT; zoomLevel++)
        {
            // If the file exists, load it
            if (File.Exists(FilePath(zoomLevel)))
            {
                statusLabel.Text = $"Loading bitmap for zoom level {zoomLevel}";

                using (Stream stream = File.OpenRead(FilePath(zoomLevel)))
                {
                    bitmaps[zoomLevel] = SKBitmap.Decode(stream);
                }
            }
            // Otherwise, create a new bitmap
            else
            {
                statusLabel.Text = $"Creating bitmap for zoom level {zoomLevel}";

                CancellationToken cancelToken = cancelTokenSource.Token;

                // Do the (generally lengthy) Mandelbrot calculation
                BitmapInfo bitmapInfo =
                    await Mandelbrot.CalculateAsync(center,
                                                    4 / Math.Pow(2, zoomLevel),
                                                    4 / Math.Pow(2, zoomLevel),
                                                    BITMAP_SIZE, BITMAP_SIZE,
                                                    (int)Math.Pow(2, 10), progressReporter, cancelToken);

                // Create bitmap & get pointer to the pixel bits
                SKBitmap bitmap = new SKBitmap(BITMAP_SIZE, BITMAP_SIZE, SKColorType.Rgba8888, SKAlphaType.Opaque);
                IntPtr basePtr = bitmap.GetPixels();

                // Set pixel bits to color based on iteration count
                for (int row = 0; row < bitmap.Width; row++)
                    for (int col = 0; col < bitmap.Height; col++)
                    {
                        int iterationCount = bitmapInfo.IterationCounts[row * bitmap.Width + col];
                        uint pixel = 0xFF000000;            // black

                        if (iterationCount != -1)
                        {
                            double proportion = (iterationCount / 32.0) % 1;
                            byte red = 0, green = 0, blue = 0;

                            if (proportion < 0.5)
                            {
                                red = (byte)(255 * (1 - 2 * proportion));
                                blue = (byte)(255 * 2 * proportion);
                            }
                            else
                            {
                                proportion = 2 * (proportion - 0.5);
                                green = (byte)(255 * proportion);
                                blue = (byte)(255 * (1 - proportion));
                            }

                            pixel = MakePixel(0xFF, red, green, blue);
                        }

                        // Calculate pointer to pixel
                        IntPtr pixelPtr = basePtr + 4 * (row * bitmap.Width + col);

                        unsafe     // requires compiling with unsafe flag
                        {
                            *(uint*)pixelPtr.ToPointer() = pixel;
                        }
                    }

                // Save as PNG file
                SKData data = SKImage.FromBitmap(bitmap).Encode();

                try
                {
                    File.WriteAllBytes(FilePath(zoomLevel), data.ToArray());
                }
                catch
                {
                    // Probably out of space, but just ignore
                }

                // Store in array
                bitmaps[zoomLevel] = bitmap;

                // Show new bitmap sizes
                TallyBitmapSizes();
            }

            // Display the bitmap
            bitmapIndex = zoomLevel;
            canvasView.InvalidateSurface();
        }

        // Now start the animation
        stopwatch.Start();
        Device.StartTimer(TimeSpan.FromMilliseconds(16), OnTimerTick);
    }
    ···
}

Observe que el programa almacena estos mapas de bits en el almacenamiento de aplicaciones local y no en la fototeca del dispositivo. La biblioteca de .NET Standard 2.0 permite usar los métodos File.OpenRead y File.WriteAllBytes conocidos para esta tarea.

Una vez creados o cargados todos los mapas de bits en la memoria, el método inicia un objeto Stopwatch y llama a Device.StartTimer. El método OnTimerTick se llama cada 16 milisegundos.

OnTimerTick calcula un valor time en milisegundos que oscila entre 0 y 6000 multiplicado por COUNT, que asigna seis segundos para la presentación de cada mapa de bits. El valor progress usa el valor Math.Sin para crear una animación sinusoidal que será más lenta al principio del ciclo y más lenta al final a medida que invierte la dirección.

El valor de progress oscila entre 0 y COUNT. Esto significa que la parte entera de progress es un índice en la matriz bitmaps, mientras que la parte fraccionaria de progress indica un nivel de zoom para ese mapa de bits determinado. Estos valores se almacenan en los campos bitmapIndex y bitmapProgress. Los muestran Label y Slider en el archivo XAML. SKCanvasView se invalida para actualizar la presentación del mapa de bits:

public partial class MainPage : ContentPage
{
    ···
    Stopwatch stopwatch = new Stopwatch();      // for the animation
    int bitmapIndex;
    double bitmapProgress = 0;
    ···
    bool OnTimerTick()
    {
        int cycle = 6000 * COUNT;       // total cycle length in milliseconds

        // Time in milliseconds from 0 to cycle
        int time = (int)(stopwatch.ElapsedMilliseconds % cycle);

        // Make it sinusoidal, including bitmap index and gradation between bitmaps
        double progress = COUNT * 0.5 * (1 + Math.Sin(2 * Math.PI * time / cycle - Math.PI / 2));

        // These are the field values that the PaintSurface handler uses
        bitmapIndex = (int)progress;
        bitmapProgress = progress - bitmapIndex;

        // It doesn't often happen that we get up to COUNT, but an exception would be raised
        if (bitmapIndex < COUNT)
        {
            // Show progress in UI
            statusLabel.Text = $"Displaying bitmap for zoom level {bitmapIndex}";
            progressBar.Progress = bitmapProgress;

            // Update the canvas
            canvasView.InvalidateSurface();
        }

        return true;
    }
    ···
}

Por último, el controlador PaintSurface de SKCanvasView calcula un rectángulo de destino para mostrar el mapa de bits lo más grande posible, mientras mantiene la relación de aspecto. Un rectángulo de origen se basa en el valor bitmapProgress. El valor fraction calculado aquí oscila entre 0 (cuando bitmapProgress es 0 para mostrar todo el mapa de bits) y 0,25 (cuando bitmapProgress es 1 para mostrar la mitad del ancho y alto del mapa de bits) lo que crea un zoom eficaz:

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

        canvas.Clear();

        if (bitmaps[bitmapIndex] != null)
        {
            // Determine destination rect as square in canvas
            int dimension = Math.Min(info.Width, info.Height);
            float x = (info.Width - dimension) / 2;
            float y = (info.Height - dimension) / 2;
            SKRect destRect = new SKRect(x, y, x + dimension, y + dimension);

            // Calculate source rectangle based on fraction:
            //  bitmapProgress == 0: full bitmap
            //  bitmapProgress == 1: half of length and width of bitmap
            float fraction = 0.5f * (1 - (float)Math.Pow(2, -bitmapProgress));
            SKBitmap bitmap = bitmaps[bitmapIndex];
            int width = bitmap.Width;
            int height = bitmap.Height;
            SKRect sourceRect = new SKRect(fraction * width, fraction * height,
                                           (1 - fraction) * width, (1 - fraction) * height);

            // Display the bitmap
            canvas.DrawBitmap(bitmap, sourceRect, destRect);
        }
    }
    ···
}

Esta es la ejecución del programa:

Animación de Mandelbrot

Animación GIF

La especificación de formato de intercambio de gráficos (GIF) incluye una característica que permite que un único archivo GIF contenga varios fotogramas secuenciales de una escena que se pueden mostrar sucesivamente, a menudo en un bucle. Estos archivos se conocen como GIF animados. Los exploradores web pueden reproducir GIF animados y SkiaSharp permite a una aplicación extraer los fotogramas de un archivo GIF animado y mostrarlos secuencialmente.

El ejemplo incluye un recurso GIF animado denominado Newtons_cradle_animation_book_2.gif creado por DemonDeLuxe y descargado de la página Cuna de Newton en Wikipedia. La página GIF animado incluye un archivo XAML que proporciona esa información y crea una instancia de SKCanvasView:

<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"
             x:Class="SkiaSharpFormsDemos.Bitmaps.AnimatedGifPage"
             Title="Animated GIF">

    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="*" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>

        <skia:SKCanvasView x:Name="canvasView"
                           Grid.Row="0"
                           PaintSurface="OnCanvasViewPaintSurface" />

        <Label Text="GIF file by DemonDeLuxe from Wikipedia Newton's Cradle page"
               Grid.Row="1"
               Margin="0, 5"
               HorizontalTextAlignment="Center" />
    </Grid>
</ContentPage>

El archivo de código subyacente no se generaliza para reproducir ningún archivo GIF animado. Omite parte de la información que está disponible, en particular un recuento de repeticiones, y simplemente reproduce el GIF animado en un bucle.

El uso de SkisSharp para extraer los fotogramas de un archivo GIF animado no parece documentarse en ningún lugar, por lo que la descripción del código siguiente es más detallada de lo habitual:

La descodificación del archivo GIF animado se produce en el constructor de la página y requiere que el objeto Stream que hace referencia al mapa de bits se use para crear un objeto SKManagedStream y, a continuación, un objeto SKCodec. La propiedad FrameCount indica el número de fotogramas que componen la animación.

Estos fotogramas se guardan finalmente como mapas de bits individuales, por lo que el constructor usa FrameCount para asignar una matriz de tipo SKBitmap, así como dos matrices int durante cada fotograma y las duraciones acumuladas (para facilitar la lógica de animación).

La propiedad FrameInfo de la clase SKCodec es una matriz de valores SKCodecFrameInfo, una para cada fotograma, pero lo único que este programa toma de esa estructura es la Duration del marco en milisegundos.

SKCodec define una propiedad denominada Info de tipo SKImageInfo, pero ese valor SKImageInfo indica (al menos para esta imagen) que el tipo de color es SKColorType.Index8, lo que significa que cada píxel es un índice en un tipo de color. Para evitar las molestias de trabajar con las tablas de colores, el programa usa la información Width y Height de esa estructura para construir su propio valor de color ImageInfo completo. Cada SKBitmap se crea a partir de eso.

El método GetPixels de SKBitmap devuelve una referencia IntPtr a los bits de píxel de ese mapa de bits. Estos bits de píxel aún no se han establecido. Este IntPtr se pasa a uno de los métodos GetPixels de SKCodec. Ese método copia el marco del archivo GIF en el espacio de memoria al que hace referencia IntPtr. El constructor SKCodecOptions indica el número de marco:

public partial class AnimatedGifPage : ContentPage
{
    SKBitmap[] bitmaps;
    int[] durations;
    int[] accumulatedDurations;
    int totalDuration;
    ···

    public AnimatedGifPage ()
    {
        InitializeComponent ();

        string resourceID = "SkiaSharpFormsDemos.Media.Newtons_cradle_animation_book_2.gif";
        Assembly assembly = GetType().GetTypeInfo().Assembly;

        using (Stream stream = assembly.GetManifestResourceStream(resourceID))
        using (SKManagedStream skStream = new SKManagedStream(stream))
        using (SKCodec codec = SKCodec.Create(skStream))
        {
            // Get frame count and allocate bitmaps
            int frameCount = codec.FrameCount;
            bitmaps = new SKBitmap[frameCount];
            durations = new int[frameCount];
            accumulatedDurations = new int[frameCount];

            // Note: There's also a RepetitionCount property of SKCodec not used here

            // Loop through the frames
            for (int frame = 0; frame < frameCount; frame++)
            {
                // From the FrameInfo collection, get the duration of each frame
                durations[frame] = codec.FrameInfo[frame].Duration;

                // Create a full-color bitmap for each frame
                SKImageInfo imageInfo = code.new SKImageInfo(codec.Info.Width, codec.Info.Height);
                bitmaps[frame] = new SKBitmap(imageInfo);

                // Get the address of the pixels in that bitmap
                IntPtr pointer = bitmaps[frame].GetPixels();

                // Create an SKCodecOptions value to specify the frame
                SKCodecOptions codecOptions = new SKCodecOptions(frame, false);

                // Copy pixels from the frame into the bitmap
                codec.GetPixels(imageInfo, pointer, codecOptions);
            }

            // Sum up the total duration
            for (int frame = 0; frame < durations.Length; frame++)
            {
                totalDuration += durations[frame];
            }

            // Calculate the accumulated durations
            for (int frame = 0; frame < durations.Length; frame++)
            {
                accumulatedDurations[frame] = durations[frame] +
                    (frame == 0 ? 0 : accumulatedDurations[frame - 1]);
            }
        }
    }
    ···
}

A pesar del valor IntPtr, no se requiere ningún código unsafe porque IntPtr nunca se convierte en un valor de puntero de C#.

Después de extraer cada fotograma, el constructor suma las duraciones de todos los fotogramas y, a continuación, inicializa otra matriz con las duraciones acumuladas.

El resto del archivo de código subyacente está dedicado a la animación. El método Device.StartTimer se usa para iniciar un temporizador y la devolución de llamada OnTimerTick usa un objeto Stopwatch para determinar el tiempo transcurrido en milisegundos. Recorrer en bucle la matriz de duraciones acumuladas es suficiente para encontrar el marco actual:

public partial class AnimatedGifPage : ContentPage
{
    SKBitmap[] bitmaps;
    int[] durations;
    int[] accumulatedDurations;
    int totalDuration;

    Stopwatch stopwatch = new Stopwatch();
    bool isAnimating;

    int currentFrame;
    ···
    protected override void OnAppearing()
    {
        base.OnAppearing();

        isAnimating = true;
        stopwatch.Start();
        Device.StartTimer(TimeSpan.FromMilliseconds(16), OnTimerTick);
    }

    protected override void OnDisappearing()
    {
        base.OnDisappearing();

        stopwatch.Stop();
        isAnimating = false;
    }

    bool OnTimerTick()
    {
        int msec = (int)(stopwatch.ElapsedMilliseconds % totalDuration);
        int frame = 0;

        // Find the frame based on the elapsed time
        for (frame = 0; frame < accumulatedDurations.Length; frame++)
        {
            if (msec < accumulatedDurations[frame])
            {
                break;
            }
        }

        // Save in a field and invalidate the SKCanvasView.
        if (currentFrame != frame)
        {
            currentFrame = frame;
            canvasView.InvalidateSurface();
        }

        return isAnimating;
    }

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

        canvas.Clear(SKColors.Black);

        // Get the bitmap and center it
        SKBitmap bitmap = bitmaps[currentFrame];
        canvas.DrawBitmap(bitmap,info.Rect, BitmapStretch.Uniform);
    }
}

Cada vez que cambia la variable currentframe, SKCanvasView se invalida y se muestra el nuevo marco:

GIF animado

Por supuesto, queremos ejecutar el programa nosotros mismos para ver la animación.