次の方法で共有


SkiaSharp ビットマップのアニメーション化

SkiaSharp グラフィックスをアニメーション化するアプリケーションでは、通常、固定レート (多くの場合 16 ミリ秒ごと) の SKCanvasViewInvalidateSurface が呼び出されます。 サーフェスを無効にすると、PaintSurface ハンドラーへの呼び出しがトリガーされ、ディスプレイが再描画されます。 ビジュアルが 1 秒に 60 回再描画されると、スムーズにアニメーション化されているように見えます。

ただし、グラフィックスが複雑すぎて 16 ミリ秒でレンダリングできない場合は、アニメーションの動きがぎこちなくなる可能性があります。 プログラマーは、リフレッシュ レートを 1 秒間に 30 回または 15 回に減らすことを選択できますが、それでも十分でない場合もあります。 グラフィックスが非常に複雑で、リアルタイムでレンダリングできない場合があります。

1 つの解決策は、一連のビットマップでアニメーションの個々のフレームをレンダリングすることによって、事前にアニメーションを準備することです。 このアニメーションを表示するには、これらのビットマップを 1 秒に 60 回連続して表示するだけで済みます。

もちろん、ビットマップの数が多くなる可能性がありますが、これは大きな予算が付く 3D アニメーション映画で用いられる方法です。 3D グラフィックスは複雑すぎるため、リアルタイムでレンダリングできません。 各フレームをレンダリングするには、多くの処理時間が必要です。 映画を見ているときに目にするものは、基本的に一連のビットマップです。

SkiaSharp でも同様のことを行うことができます。 この記事では、2 種類のビットマップ アニメーションについて説明します。 最初の例は、マンデルブロ集合のアニメーションです。

サンプルのアニメーション化

2 番目の例は、SkiaSharp を使用してアニメーション GIF ファイルをレンダリングする方法を示します。

ビットマップ アニメーション

マンデルブロ集合は視覚的に美しく見えますが、計算には時間がかかります。 (マンデルブロ集合と、ここで使用する数学的な処理については、「Xamarin.Forms を使用したモバイル アプリの作成」の 666 ページから始まる第 20 章を参照してください。以下の説明では、その背景知識を前提としています)。

このサンプルでは、ビットマップ アニメーションを使用して、マンデルブロ集合内の固定点の連続ズームをシミュレートします。 ズームインの後にズームアウトが続き、プログラムが終了するまでこのサイクルがずっと繰り返されます。

このアニメーションの準備としてプログラムでは、アプリケーションのローカル ストレージに格納される最大 50 個のビットマップを作成します。 各ビットマップは、前のビットマップの半分の幅と高さの複素平面を包含します (このプログラムでは、これらのビットマップは、整数のズーム レベルを表しています)。その後、ビットマップが順番に表示されます。 各ビットマップのスケーリングは、1 つのビットマップから別のビットマップへスムーズに進むようにアニメーション化されます。

Xamarin.Forms を使用したモバイル アプリの作成」の第 20 章で説明されている最後のプログラムと同様に、マンデルブロ アニメーションのマンデルブロ集合の計算は、8 つのパラメーターを持つ非同期メソッドで行われます。 パラメーターには、複素数の中心点と、その中心点を囲む複素平面の幅と高さが含まれます。 次の 3 つのパラメーターは、作成するビットマップのピクセルの幅と高さ、および再帰的な計算の反復の最大回数です。 この 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 ファイルには、2 つの Label ビューと ProgressBarButtonSKCanvasView が含まれています。

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

分離コード ファイルは、3 つの重要な定数とビットマップの配列を定義することから始まります。

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 程度で、倍精度浮動小数点数の解像度がマンデルブロ集合の計算には不十分になります。 この問題については、「Xamarin.Forms を使用したモバイル アプリの作成」の 684 ページで説明されています。

center 値は非常に重要です。 これがアニメーション ズームのフォーカスになります。 ファイル内の 3 つの値は、「Xamarin.Forms を使用したモバイル アプリの作成」の 684 ページ第 20 章の 3 つの最後のスクリーンショットで使用されていますが、その章のプログラムを試して、独自の値を見出すことができます。

マンデルブロアニメーション サンプルでは、これらの COUNT ビットマップをローカル アプリケーション ストレージに格納します。 50 個のビットマップでは、デバイス上に 20 MB を超えるストレージが必要であるため、これらのビットマップが占有しているストレージの量を把握し、ある時点ですべてのビットマップを削除する必要があるかもしれません。 これは、MainPage クラスの下部にある次の 2 つのメソッドの目的です。

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 メソッドは、プログラムが以前に実行されたときに作成された可能性のあるビットマップを読み込むために、アプリケーション ローカル ストレージにアクセスします。 0 から COUNT までの zoomLevel 値をループ処理します。 ファイルが存在する場合は、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.OpenReadFile.WriteAllBytes メソッドをこのタスクに使用できます。

すべてのビットマップが作成されたか、メモリに読み込まれたら、メソッドは Stopwatch オブジェクトを開始して、Device.StartTimer を呼び出します。 OnTimerTick メソッドは、16 ミリ秒ごとに呼び出されます。

OnTimerTick は、0 から 6,000 倍の COUNT 範囲で time 値をミリ秒単位で計算し、各ビットマップの表示に 6 秒を割り当てます。 progress 値は Math.Sin 値を使用して、正弦波のアニメーションを作成します。サイクルの開始時に速度が遅くなり、逆方向に向かう最後にも遅くなります。

progress 値の範囲は 0 から COUNT です。 つまり、整数部分 progressbitmaps 配列へのインデックスであり、progress の小数部分はその特定のビットマップのズーム レベルを示します。 これらの値は、bitmapIndexbitmapProgress フィールドに格納され、XAML ファイルの LabelSlider で表示されます。 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;
    }
    ···
}

最後に、SKCanvasViewPaintSurface ハンドラーは、縦横比を維持しながら、ビットマップを可能な限り大きく表示するために到達点となる四角形を計算します。 ソース四角形は 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) 仕様には、1 つの GIF ファイルに連続して表示できるシーンの複数の連続フレーム (多くの場合ループ) を含めることができる機能があります。 これらのファイルは "アニメーション GIF" と呼ばれます。 Web ブラウザーはアニメーション GIF を再生できます。SkiaSharp を使用すると、アプリケーションはアニメーション GIF ファイルからフレームを抽出し、それらを順番に表示できます。

このサンプルには、DemonDeLuxe によって作成された Newtons_cradle_animation_book_2.gif という名前のアニメーション GIF リソースが含まれています。これは、Wikipedia の「ニュートンのゆりかご」のページからダウンロードしました。 アニメーション GIF ページには、その情報を提供し、SKCanvasView をインスタンス化する XAML ファイルが含まれています。

<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 の配列と 2 つの int 配列を割り当てます。

SKCodec クラスの FrameInfo プロパティは、SKCodecFrameInfo 値の配列 (フレームごとに 1 つずつ) になりますが、このプログラムがその構造体から受け取る唯一のものは、ミリ秒単位のフレームの Duration になります。

SKCodecSKImageInfo 型の Info という名前のプロパティを定義しますが、その SKImageInfo 値は色の種類が SKColorType.Index8 (少なくともこの画像の場合) であることを示します。これは、各ピクセルが色の種類のインデックスであることを意味します。 カラー テーブルを扱う手間を省くために、プログラムはその構造体の WidthHeight の情報を使用して、独自のフル カラー ImageInfo 値を作成します。 それぞれ SKBitmap がそこから作成されます。

SKBitmapGetPixels メソッドは、そのビットマップのピクセル ビットを参照する IntPtr 値を返します。 これらのピクセル ビットはまだ設定されていません。 その IntPtr は、SKCodecGetPixels メソッドのいずれかに渡されます。 そのメソッドは、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 値にかかわらず、IntPtr は C# ポインター値に変換されないため、unsafe コードは必要ありません。

各フレームが抽出されると、コンストラクターはすべてのフレームの期間を合計し、累積された期間で別の配列を初期化します。

分離コード ファイルの残りの部分はアニメーション専用です。 この 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

もちろん、プログラムを自分で実行してアニメーションを表示する必要があります。