Share via


建立 SkiaSharp 位圖的動畫效果

建立 SkiaSharp 圖形動畫的應用程式通常會以固定速率呼叫 InvalidateSurfaceSKCanvasView ,通常每 16 毫秒一次。 使介面失效會觸發對處理程式的 PaintSurface 呼叫,以重新繪製顯示器。 當視覺效果每秒重繪 60 次時,它們看起來會以流暢的動畫方式呈現。

不過,如果圖形太複雜而無法以 16 毫秒轉譯,動畫可能會變得抖動。 程序設計人員可能會選擇將重新整理率降低到每秒 30 次或 15 次,但有時甚至還不夠。 有時候圖形非常複雜,因此無法實時轉譯。

其中一個解決方案是事先準備動畫,方法是在一系列位圖上轉譯動畫的個別畫面格。 若要顯示動畫,只需要以每秒 60 次的順序顯示這些點陣圖。

當然,這很可能有很多位圖,但這就是製作預算 3D 動畫電影的方式。 3D 圖形太複雜,無法實時轉譯。 轉譯每個畫面需要大量的處理時間。 當您看電影時看到的內容基本上是一系列的點陣圖。

您可以在 SkiaSharp 中執行類似動作。 本文示範兩種類型的位圖動畫。 第一個範例是 Mandelbrot Set 的動畫:

動畫範例

第二個範例示範如何使用 SkiaSharp 來呈現動畫 GIF 檔案。

點陣圖動畫

曼德爾布羅特集在視覺上引人入勝,但冗長。 (如需曼德爾布羅特集的討論和這裡使用的數學,請參閱從第 666 頁開始建立 Mobile Apps Xamarin.Forms 的第 20 章。下列描述假設背景知識。

此範例會使用位圖動畫來模擬 Mandelbrot Set 中固定點的連續縮放。 放大后再放大,然後迴圈會永遠重複,直到您結束程序為止。

此程式會建立最多 50 個點陣圖,儲存在應用程式本機記憶體中,以準備此動畫。 每個點陣圖都包含複雜平面的一半寬度和高度,做為上一個點陣圖。 (在程式中,這些點圖據說代表整數 縮放比例。然後,點陣圖會依序顯示。 每個點陣圖的縮放比例都會以動畫顯示,以提供從一個點陣圖到另一個點陣圖的平滑進展。

如同使用 建立Mobile Apps Xamarin.Forms第 20 章中所述的最後程式,Mandelbrot AnimationMandelbrot Set 的計算是具有八個參數的異步方法。 這些參數包括複雜的中心點,以及圍繞該中心點的複雜平面寬度和高度。 接下來的三個參數是要建立之位圖的圖元寬度和高度,以及遞歸計算的最大反覆項目數目。 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; }
}

Mandelbrot 動畫 XAML 檔案包含兩Label個檢視、、 ProgressBarButton 以及 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 計算。 此問題會在使用 Xamarin.Forms建立Mobile Apps的第684頁討論。

此值 center 非常重要。 這是動畫縮放的焦點。 檔案中的三個值是在第 684 頁建立 Mobile Apps Xamarin.Forms 第 20 章的最後三個螢幕快照中使用的值,但您可以在該章節中實驗程式,以想出您自己的其中一個值。

Mandelbrot 動畫範例會將這些COUNT點陣圖儲存在本機應用程式記憶體中。 五十個點陣圖在您的裝置上需要超過 20 MB 的記憶體,因此您可能想要知道這些點陣圖佔用多少記憶體,而且在某些時候,您可能會想要刪除這些位圖。 這就是類別底部這兩種方法的目的 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);
    ···
}

FilePath範圍zoomLevel從 0 到常數減 1 的參數COUNT

MainPage 構函式會 LoadAndStartAnimation 呼叫 方法:

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

        LoadAndStartAnimation();
    }
    ···
}

方法 LoadAndStartAnimation 負責存取應用程式本機記憶體,以載入先前執行程式時可能建立的任何點陣圖。 它會迴圈查看 zoomLevel 從 0 到 COUNT的值。 如果檔案存在,它會將它 bitmaps 載入數位列中。 否則,它必須藉由呼叫 Mandelbrot.CalculateAsync來建立特定 centerzoomLevel 值的點陣圖。 該方法會取得每個像素的反覆項目計數,此方法會轉換成色彩:

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 毫秒呼叫一次。

OnTimerTicktime 以毫秒為單位來計算值,範圍從 0 到 6000 次 COUNT,這會為每個點陣圖的顯示計算六秒。 值 progressMath.Sin 使用 值來建立迴圈開頭較慢的正弦動畫,並在反向方向時於結尾變慢。

progress 的範圍從 0 到 COUNT。 這表示 的整數部分 progress 是陣列中的 bitmaps 索引,而的分數部分 progress 則表示該特定位圖的縮放層級。 這些值會儲存在 bitmapIndexbitmapProgress 欄位中,並由和 Slider 顯示在 Label 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;
    }
    ···
}

最後,的 PaintSurfaceSKCanvasView 處理程式會計算目的矩形,以盡可能大地顯示位圖,同時維持外觀比例。 來源矩形是以 值為基礎 bitmapProgressfraction此處計算的值範圍從 0 當 為 0 以顯示整個點陣圖,bitmapProgress到 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);
        }
    }
    ···
}

以下是程式執行情況:

Mandelbrot 動畫

GIF 動畫

圖形交換格式 (GIF) 規格包含一項功能,可讓單一 GIF 檔案包含可以連續顯示之場景的多個循序畫面格,通常是在迴圈中顯示。 這些檔案稱為 動畫 GIF。 網頁瀏覽器可以播放動畫 GIF,而 SkiaSharp 可讓應用程式從動畫 GIF 檔案擷取畫面格,並循序顯示它們。

此範例包含名為 demonDeLuxe 所建立Newtons_cradle_animation_book_2.gif動畫 GIF 資源,並從維琪百科的牛頓搖籃頁面下載。 [ 動畫 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 個陣列,以及(以簡化動畫邏輯)累積的持續時間。

類別 FrameInfoSKCodec 屬性是一個值陣列 SKCodecFrameInfo ,每個框架各有一個,但這個程式唯一從該結構 Duration 取得的就是以毫秒為單位的框架。

SKCodec會定義名為 類型的SKImageInfo屬性Info,但該值SKImageInfo表示色彩類型為 SKColorType.Index8,這表示每個圖元都是色彩類型的索引。 為了避免對色彩數據表造成困擾,程式會使用該 Width 結構的 和 Height 信息來建構其本身的完整色彩 ImageInfo 值。 每個 SKBitmap 都是從中建立的。

GetPixels 方法 SKBitmapIntPtr 傳回參考該位圖的圖元。 尚未設定這些圖元位。 這IntPtr會傳遞至 的SKCodec其中GetPixels一個方法。 該方法會將框架從 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);
    }
}

每次 currentframe 變數變更時,都會 SKCanvasView 失效並顯示新的框架:

動畫 GIF

當然,您會想要自行執行程式來查看動畫。