Поделиться через


Анимация растровых карт SkiaSharp

Приложения, которые анимация графики SkiaSharp обычно вызываются InvalidateSurface на SKCanvasView фиксированной частоте, часто каждые 16 миллисекундах. Недопустимое срабатывание поверхности вызывает вызов PaintSurface обработчика для перераспроверки дисплея. Так как визуальные элементы перерисовываются 60 раз в секунду, они, как представляется, плавно анимированы.

Тем не менее, если графика слишком сложна для отрисовки в 16 миллисекундах, анимация может стать сложной. Программист может сократить частоту обновления до 30 раз или 15 раз в секунду, но иногда даже это недостаточно. Иногда графики настолько сложны, что они просто не могут быть отрисованы в режиме реального времени.

Одним из решений является подготовка к анимации заранее путем отрисовки отдельных кадров анимации на ряде растровых изображений. Чтобы отобразить анимацию, необходимо отобразить только эти растровые изображения последовательно 60 раз в секунду.

Конечно, это потенциально много растровых изображений, но это то, как делаются трехмерные анимационные фильмы. Трехмерная графика слишком сложна для отрисовки в режиме реального времени. Для отрисовки каждого кадра требуется много времени обработки. То, что вы видите при просмотре фильма, по сути, это ряд растровых изображений.

Вы можете сделать что-то подобное в SkiaSharp. В этой статье демонстрируется два типа анимации растрового изображения. Первый пример — анимация набора Mandelbrot:

Пример анимации

Во втором примере показано, как использовать SkiaSharp для отрисовки анимированного GIF-файла.

Анимация растрового рисунка

Мандельброт Набор визуально увлекательный, но сомнительно длинный. (Обсуждение набора Мандельброта и математики, используемой здесь, см. в разделе Глава 20 о создании мобильных приложений с Xamarin.Forms начала страницы 666. В следующем описании предполагается, что фоновые знания.)

В примере используется анимация растрового изображения для имитации непрерывного увеличения фиксированной точки в наборе Mandelbrot. За увеличением следует увеличение масштаба, а затем цикл повторяется навсегда или пока не завершите программу.

Программа готовится к этой анимации, создавая до 50 растровых изображений, которые он хранит в локальном хранилище приложения. Каждая растровая карта охватывает половину ширины и высоты сложного плоскости, как предыдущее растровое изображение. (В программе эти растровые изображения, как говорят, представляют целые уровни масштабирования.) Затем растровые изображения отображаются в последовательности. Масштабирование каждой растровой карты анимировано, чтобы обеспечить плавное прогрессирование из одной растровой карты в другую.

Как и последняя программа, описанная в главе 20 создания мобильных приложений сXamarin.Forms помощью, вычисление набора Mandelbrot в Анимации Mandelbrot является асинхронным методом с восемью параметрами. Параметры включают сложную центровую точку, а также ширину и высоту сложной плоскости, окружающей этот центр. Следующие три параметра — это ширина пикселя и высота создаваемого растрового изображения, а также максимальное количество итераций для рекурсивного вычисления. Параметр progress используется для отображения хода выполнения этого вычисления. Параметр cancelToken не используется в этой программе:

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

Метод возвращает объект типа BitmapInfo , предоставляющий сведения о создании растрового изображения:

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

XAML-файл анимации Mandelbrot включает два Label представления, a ProgressBarи aButton, а также 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>

Файл кода начинается с определения трех важных констант и массива растровых изображений:

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

В какой-то момент вы, вероятно, хотите изменить COUNT значение на 50, чтобы увидеть полный диапазон анимации. Значения выше 50 не полезны. Вокруг уровня масштабирования 48 или около того разрешение чисел с плавающей запятой двойной точности становится недостаточным для вычисления Mandelbrot Set. Эта проблема рассматривается на странице 684 создания мобильных приложений с Xamarin.Formsпомощью .

Значение center очень важно. Это фокус масштабирования анимации. Три значения в файле — это те, которые используются на трех последних снимках экрана в главе 20 создания мобильных приложений с Xamarin.Forms помощью страницы 684, но вы можете поэкспериментировать с программой в этой главе, чтобы придумать одно из ваших собственных значений.

В примере анимации Mandelbrot эти растровые изображения хранятся COUNT в локальном хранилище приложений. Пятьдесят растровых карт требуют более 20 мегабайт хранилища на устройстве, поэтому вам может потребоваться знать, сколько занимает хранилище этих растровых карт, и в какой-то момент вы можете удалить их все. Это цель этих двух методов в нижней 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();
    }
}

Вы можете удалить растровые изображения в локальном хранилище, а программа выполняет анимацию этих же растровых изображений, так как программа сохраняет их в памяти. Но при следующем запуске программы потребуется повторно создать растровые изображения.

Растровые изображения, хранящиеся в локальном хранилище приложений, включают center значение в именах файлов, поэтому при изменении center параметра существующие растровые изображения не будут заменены в хранилище и будут продолжать занимать место.

Ниже приведены методы, которые MainPage используются для создания имен файлов, а также MakePixel метода для определения значения пикселя на основе компонентов цвета:

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

Параметр zoomLevel FilePath для диапазона от 0 до COUNT константы минус 1.

Конструктор MainPage вызывает LoadAndStartAnimation метод:

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

        LoadAndStartAnimation();
    }
    ···
}

Метод LoadAndStartAnimation отвечает за доступ к локальному хранилищу приложения для загрузки любых растровых карт, которые могли быть созданы при запуске программы ранее. Он циклит по zoomLevel значениям от 0 до COUNT. Если файл существует, он загружает его в bitmaps массив. В противном случае необходимо создать растровое изображение для конкретного center и zoomLevel значения путем вызова Mandelbrot.CalculateAsync. Этот метод получает количество итераций для каждого пикселя, который этот метод преобразуется в цвета:

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

Обратите внимание, что программа хранит эти растровые изображения в локальном хранилище приложений, а не в библиотеке фотографий устройства. Библиотека .NET Standard 2.0 позволяет использовать знакомые File.OpenRead и File.WriteAllBytes методы для этой задачи.

После создания или загрузки в память всех растровых изображений метод запускает Stopwatch объект и вызовы Device.StartTimer. Метод OnTimerTick вызывается каждые 16 миллисекундах.

OnTimerTick вычисляет значение в миллисекундах time , которое колеблется от 0 до 6000 раз COUNT, которое определяет шесть секунд для отображения каждого растрового изображения. Значение progress используется Math.Sin для создания синусоидальной анимации, которая будет медленнее в начале цикла, и медленнее в конце, так как она меняет направление.

Значение progress диапазонов от 0 до COUNT. Это означает, что целочисленная часть является индексом в bitmaps массиве, а дробная часть progress progress указывает уровень масштабирования для конкретного растрового изображения. Эти значения хранятся в bitmapIndex полях и bitmapProgress полях и отображаются Label Slider в XAML-файле. Недействителен SKCanvasView для обновления отображения растрового изображения:

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

Наконец, PaintSurface обработчик вычисляет прямоугольник SKCanvasView назначения, чтобы отобразить растровое изображение как можно больше при сохранении пропорции. Исходный прямоугольник основан на значении bitmapProgress . Значение fraction , вычисляемое здесь, диапазон от 0, если bitmapProgress равен 0 для отображения всего растрового изображения, до 0,25, если bitmapProgress значение равно 1, чтобы отобразить половину ширины и высоты растрового изображения, эффективно масштабируя:

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

Вот работающая программа:

Анимация Мандельброта

Анимация GIF

Спецификация формата обмена графики (GIF) включает функцию, которая позволяет одному GIF-файлу содержать несколько последовательных кадров сцены, которые могут отображаться в последовательности, часто в цикле. Эти файлы называются анимированными GIF-файлами. Веб-браузеры могут воспроизводить анимированные GIF-файлы, и SkiaSharp позволяет приложению извлекать кадры из анимированного GIF-файла и отображать их последовательно.

Пример включает анимированный ресурс GIF с именем Newtons_cradle_animation_book_2.gif , созданный демономDeLuxe и скачанный с страницы Колыбель Ньютона в Википедии. Страница анимированного GIF-файла содержит XAML-файл, предоставляющий эти сведения и создающий экземпляр :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>

Файл программной части не обобщен для воспроизведения анимированного GIF-файла. Он игнорирует некоторые сведения, доступные, в частности количество повторений, и просто воспроизводит анимированный GIF-файл в цикле.

Использование SkisSharp для извлечения кадров анимированного GIF-файла не документируется в любом месте, поэтому описание следующего кода более подробно, чем обычно:

Декодирование анимированного GIF-файла происходит в конструкторе страницы и требует, чтобы Stream объект, ссылающийся на растровое изображение, использовался для создания SKManagedStream объекта, а затем SKCodec объекта. Свойство FrameCount указывает количество кадров, составляющих анимацию.

Эти кадры в конечном итоге сохраняются в виде отдельных растровых карт, поэтому конструктор использует FrameCount для выделения массива типов SKBitmap , а также двух int массивов для длительности каждого кадра и (для упрощения логики анимации) накопленных длительности.

FrameInfo Свойство SKCodec класса — это массив значенийSKCodecFrameInfo, по одному для каждого кадра, но единственное, что эта программа принимает из этой структуры кадр Duration в миллисекундах.

SKCodec определяет свойство с именем Info типа SKImageInfo, но это SKImageInfo значение указывает (по крайней мере для этого изображения), что тип SKColorType.Index8цвета , что означает, что каждый пиксель является индексом в тип цвета. Чтобы избежать проблем с таблицами цветов, программа использует Width и Height информацию из этой структуры для создания полноцветного ImageInfo значения. Каждый SKBitmap создается из этого.

Метод GetPixels SKBitmap возвращает ссылку IntPtr на биты пикселей этого растрового изображения. Эти биты пикселей еще не заданы. Это IntPtr передается одному из GetPixels методов SKCodec. Этот метод копирует кадр из GIF-файла в пространство памяти, на которое ссылается объект IntPtr. Конструктор SKCodecOptions указывает номер кадра:

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

IntPtr Несмотря на это значение, код не unsafe требуется, так как он IntPtr никогда не преобразуется в значение указателя C#.

После извлечения каждого кадра конструктор суммирует длительность всех кадров, а затем инициализирует другой массив с накопленными длительностью.

Оставшаяся часть файла кода выделена для анимации. Метод Device.StartTimer используется для запуска таймера, а OnTimerTick обратный вызов использует Stopwatch объект для определения истекшего времени в миллисекундах. Для поиска текущего кадра достаточно цикла по массиву накопленных длительности:

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

При каждом изменении SKCanvasView переменной currentframe недопустимый и отображается новый кадр:

Анимированный GIF-файл

Конечно, вы хотите запустить программу самостоятельно, чтобы увидеть анимацию.