Partilhar via


Animando bitmaps SkiaSharp

Os aplicativos que animam gráficos SkiaSharp geralmente ligam InvalidateSurface para o a uma taxa fixa, geralmente a SKCanvasView cada 16 milissegundos. A invalidação da superfície dispara uma chamada ao PaintSurface manipulador para redesenhar a exibição. Como os visuais são redesenhados 60 vezes por segundo, eles parecem ser animados suavemente.

No entanto, se os gráficos forem muito complexos para serem renderizados em 16 milissegundos, a animação pode ficar nervosa. O programador pode optar por reduzir a taxa de atualização para 30 vezes ou 15 vezes por segundo, mas às vezes nem isso é suficiente. Às vezes, os gráficos são tão complexos que simplesmente não podem ser renderizados em tempo real.

Uma solução é se preparar para a animação com antecedência, renderizando os quadros individuais da animação em uma série de bitmaps. Para exibir a animação, só é necessário exibir esses bitmaps sequencialmente 60 vezes por segundo.

Claro, isso é potencialmente um monte de bitmaps, mas é assim que filmes animados 3D de grande orçamento são feitos. Os gráficos 3D são complexos demais para serem renderizados em tempo real. Muito tempo de processamento é necessário para renderizar cada quadro. O que você vê quando assiste ao filme é essencialmente uma série de bitmaps.

Você pode fazer algo semelhante no SkiaSharp. Este artigo demonstra dois tipos de animação de bitmap. O primeiro exemplo é uma animação do Conjunto de Mandelbrot:

Exemplo de animação

O segundo exemplo mostra como usar SkiaSharp para renderizar um arquivo GIF animado.

Animação de bitmap

O Conjunto Mandelbrot é visualmente fascinante, mas computacionalmente longo. (Para uma discussão sobre o Conjunto de Mandelbrot e a matemática usada aqui, veja Capítulo 20 de Criação de aplicativos móveis com Xamarin.Forms início na página 666. A descrição a seguir pressupõe esse conhecimento prévio.)

O exemplo usa animação de bitmap para simular um zoom contínuo de um ponto fixo no Conjunto de Mandelbrot. O zoom é seguido pelo zoom reduzido e, em seguida, o ciclo se repete para sempre ou até que você termine o programa.

O programa se prepara para essa animação criando até 50 bitmaps que armazena no armazenamento local do aplicativo. Cada bitmap engloba metade da largura e altura do plano complexo como o bitmap anterior. (No programa, esses bitmaps representam níveis de zoom integral.) Os bitmaps são exibidos em sequência. O dimensionamento de cada bitmap é animado para fornecer uma progressão suave de um bitmap para outro.

Como o programa final descrito no Capítulo 20 de Criando aplicativos móveis com Xamarin.Forms, o cálculo do conjunto de Mandelbrot na animação de Mandelbrot é um método assíncrono com oito parâmetros. Os parâmetros incluem um ponto central complexo e uma largura e altura do plano complexo em torno desse ponto central. Os próximos três parâmetros são a largura e a altura do pixel do bitmap a ser criado e um número máximo de iterações para o cálculo recursivo. O progress parâmetro é usado para exibir o progresso desse cálculo. O cancelToken parâmetro não é usado neste 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);
    }
}

O método retorna um objeto do tipo BitmapInfo que fornece informações para criar um bitmap:

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

O arquivo XAML da Animação de Mandelbrot inclui dois Label modos de exibição, um ProgressBare um Button , bem como o 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>

O arquivo code-behind começa definindo três constantes cruciais e uma matriz de bitmaps:

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

Em algum momento, você provavelmente desejará alterar o COUNT valor para 50 para ver todo o alcance da animação. Valores acima de 50 não são úteis. Em torno de um nível de zoom de 48 ou mais, a resolução de números de ponto flutuante de precisão dupla torna-se insuficiente para o cálculo do Conjunto de Mandelbrot. Esse problema é discutido na página 684 de Criando aplicativos móveis com Xamarin.Formso .

O center valor é muito importante. Este é o foco do zoom de animação. Os três valores no arquivo são aqueles usados nas três capturas de tela finais no Capítulo 20 de Criando aplicativos móveis com Xamarin.Forms na página 684, mas você pode experimentar o programa nesse capítulo para criar um de seus próprios valores.

O exemplo de animação de Mandelbrot armazena esses COUNT bitmaps no armazenamento de aplicativos locais. Cinquenta bitmaps exigem mais de 20 megabytes de armazenamento em seu dispositivo, então você pode querer saber quanto armazenamento esses bitmaps estão ocupando e, em algum momento, talvez queira excluí-los todos. Esse é o objetivo desses dois métodos na parte inferior da MainPage classe:

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

Você pode excluir os bitmaps no armazenamento local enquanto o programa estiver animando esses mesmos bitmaps porque o programa os retém na memória. Mas da próxima vez que você executar o programa, ele precisará recriar os bitmaps.

Os bitmaps armazenados no armazenamento de aplicativos locais incorporam o center valor em seus nomes de arquivo, portanto, se você alterar a center configuração, os bitmaps existentes não serão substituídos no armazenamento e continuarão a ocupar espaço.

Aqui estão os métodos que MainPage usa para construir os nomes de arquivos, bem como um MakePixel método para definir um valor de pixel com base em componentes de cor:

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

O zoomLevel parâmetro a FilePath varia de 0 à COUNT constante menos 1.

O MainPage construtor chama o LoadAndStartAnimation método:

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

        LoadAndStartAnimation();
    }
    ···
}

O LoadAndStartAnimation método é responsável por acessar o armazenamento local do aplicativo para carregar quaisquer bitmaps que possam ter sido criados quando o programa foi executado anteriormente. Ele faz um loop através zoomLevel de valores de 0 a COUNT. Se o arquivo existir, ele o carregará na bitmaps matriz. Caso contrário, ele precisará criar um bitmap para o particular center e zoomLevel valores chamando Mandelbrot.CalculateAsync. Esse método obtém as contagens de iteração para cada pixel, que esse método converte em cores:

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 o programa armazena esses bitmaps no armazenamento de aplicativos local em vez de na biblioteca de fotos do dispositivo. A biblioteca do .NET Standard 2.0 permite usar os métodos e familiares File.OpenRead para File.WriteAllBytes essa tarefa.

Depois que todos os bitmaps tiverem sido criados ou carregados na memória, o método inicia um Stopwatch objeto e chama Device.StartTimer. O OnTimerTick método é chamado a cada 16 milissegundos.

OnTimerTick Calcula um time valor em milissegundos que varia de 0 a 6000 vezes COUNT, que distribui seis segundos para a exibição de cada bitmap. O progress valor usa o Math.Sin valor para criar uma animação senoidal que será mais lenta no início do ciclo e mais lenta no final, pois inverte a direção.

O progress valor varia de 0 a COUNT. Isso significa que a parte inteira de progress é um índice na bitmaps matriz, enquanto a parte fracionária de indica um nível de progress zoom para esse bitmap específico. Esses valores são armazenados nos bitmapIndex campos e bitmapProgress e exibidos pelo Label e Slider no arquivo XAML. O SKCanvasView é invalidado para atualizar a exibição de bitmap:

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

Finalmente, o PaintSurface manipulador do calcula um retângulo de SKCanvasView destino para exibir o bitmap o maior possível, mantendo a proporção. Um retângulo de origem é baseado no bitmapProgress valor. O fraction valor calculado aqui varia de 0 quando bitmapProgress é 0 para exibir o bitmap inteiro a 0,25 quando bitmapProgress é 1 para exibir metade da largura e altura do bitmap, efetivamente ampliando:

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

Este é o programa em execução:

Mandelbrot Animação

Animação GIF

A especificação GIF (Graphics Interchange Format) inclui um recurso que permite que um único arquivo GIF contenha vários quadros sequenciais de uma cena que podem ser exibidos em sucessão, geralmente em um loop. Esses arquivos são conhecidos como GIFs animados. Os navegadores da Web podem reproduzir GIFs animados e o SkiaSharp permite que um aplicativo extraia os quadros de um arquivo GIF animado e os exiba sequencialmente.

O exemplo inclui um recurso GIF animado chamado Newtons_cradle_animation_book_2.gif criado por DemonDeLuxe e baixado da página Newton's Cradle na Wikipedia. A página GIF animado inclui um arquivo XAML que fornece essas informações e instancia um 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>

O arquivo code-behind não é generalizado para reproduzir qualquer arquivo GIF animado. Ele ignora algumas das informações disponíveis, em particular uma contagem de repetição, e simplesmente reproduz o GIF animado em um loop.

O uso de SkisSharp para extrair os quadros de um arquivo GIF animado não parece estar documentado em nenhum lugar, então a descrição do código a seguir é mais detalhada do que o normal:

A decodificação do arquivo GIF animado ocorre no construtor da página e requer que o Stream objeto que faz referência ao bitmap seja usado para criar um SKManagedStream objeto e, em seguida, um SKCodec objeto. A FrameCount propriedade indica o número de quadros que compõem a animação.

Esses quadros são eventualmente salvos como bitmaps individuais, então o construtor usa FrameCount para alocar uma matriz de tipo SKBitmap , bem como duas int matrizes para a duração de cada quadro e (para facilitar a lógica de animação) as durações acumuladas.

A FrameInfo propriedade de SKCodec classe é uma matriz de SKCodecFrameInfo valores, um para cada quadro, mas a única coisa que este programa tira dessa estrutura é o Duration do quadro em milissegundos.

SKCodec define uma propriedade chamada Info de tipo SKImageInfo, mas esse SKImageInfo valor indica (pelo menos para esta imagem) que o tipo de cor é SKColorType.Index8, o que significa que cada pixel é um índice em um tipo de cor. Para evitar se incomodar com tabelas de cores, o programa usa as Width informações e Height dessa estrutura para construir seu próprio valor de cor ImageInfo completa. Cada um SKBitmap é criado a partir disso.

O GetPixels método de SKBitmap retorna uma IntPtr referência aos bits de pixel desse bitmap. Esses bits de pixel ainda não foram definidos. Isso IntPtr é passado para um dos GetPixels métodos de SKCodec. Esse método copia o quadro do arquivo GIF para o espaço de memória referenciado pelo IntPtr. O SKCodecOptions construtor indica o número do quadro:

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

Apesar do IntPtr valor, nenhum unsafe código é necessário porque o IntPtr nunca é convertido em um valor de ponteiro C#.

Depois que cada quadro tiver sido extraído, o construtor totaliza as durações de todos os quadros e, em seguida, inicializa outra matriz com as durações acumuladas.

O restante do arquivo code-behind é dedicado à animação. O Device.StartTimer método é usado para iniciar um temporizador em andamento, e o OnTimerTick retorno de chamada usa um Stopwatch objeto para determinar o tempo decorrido em milissegundos. Percorrer a matriz de durações acumuladas é suficiente para localizar o quadro atual:

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 a currentframe variável é alterada, o é invalidado SKCanvasView e o novo quadro é exibido:

GIF animado

Claro, você vai querer executar o programa sozinho para ver a animação.