Анимация растровых карт 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
недопустимый и отображается новый кадр:
Конечно, вы хотите запустить программу самостоятельно, чтобы увидеть анимацию.