Animando bitmaps SkiaSharp
Os aplicativos que animam gráficos SkiaSharp geralmente ligam InvalidateSurface
para o a uma taxa fixa, geralmente a SKCanvasView
cada 16 milissegundos. A invalidação da superfície dispara uma chamada ao PaintSurface
manipulador para redesenhar a exibição. Como os visuais são redesenhados 60 vezes por segundo, eles parecem ser animados suavemente.
No entanto, se os gráficos forem muito complexos para serem renderizados em 16 milissegundos, a animação pode ficar nervosa. O programador pode optar por reduzir a taxa de atualização para 30 vezes ou 15 vezes por segundo, mas às vezes nem isso é suficiente. Às vezes, os gráficos são tão complexos que simplesmente não podem ser renderizados em tempo real.
Uma solução é se preparar para a animação com antecedência, renderizando os quadros individuais da animação em uma série de bitmaps. Para exibir a animação, só é necessário exibir esses bitmaps sequencialmente 60 vezes por segundo.
Claro, isso é potencialmente um monte de bitmaps, mas é assim que filmes animados 3D de grande orçamento são feitos. Os gráficos 3D são complexos demais para serem renderizados em tempo real. Muito tempo de processamento é necessário para renderizar cada quadro. O que você vê quando assiste ao filme é essencialmente uma série de bitmaps.
Você pode fazer algo semelhante no SkiaSharp. Este artigo demonstra dois tipos de animação de bitmap. O primeiro exemplo é uma animação do Conjunto de Mandelbrot:
O segundo exemplo mostra como usar SkiaSharp para renderizar um arquivo GIF animado.
Animação de bitmap
O Conjunto Mandelbrot é visualmente fascinante, mas computacionalmente longo. (Para uma discussão sobre o Conjunto de Mandelbrot e a matemática usada aqui, veja Capítulo 20 de Criação de aplicativos móveis com Xamarin.Forms início na página 666. A descrição a seguir pressupõe esse conhecimento prévio.)
O exemplo usa animação de bitmap para simular um zoom contínuo de um ponto fixo no Conjunto de Mandelbrot. O zoom é seguido pelo zoom reduzido e, em seguida, o ciclo se repete para sempre ou até que você termine o programa.
O programa se prepara para essa animação criando até 50 bitmaps que armazena no armazenamento local do aplicativo. Cada bitmap engloba metade da largura e altura do plano complexo como o bitmap anterior. (No programa, esses bitmaps representam níveis de zoom integral.) Os bitmaps são exibidos em sequência. O dimensionamento de cada bitmap é animado para fornecer uma progressão suave de um bitmap para outro.
Como o programa final descrito no Capítulo 20 de Criando aplicativos móveis com Xamarin.Forms, o cálculo do conjunto de Mandelbrot na animação de Mandelbrot é um método assíncrono com oito parâmetros. Os parâmetros incluem um ponto central complexo e uma largura e altura do plano complexo em torno desse ponto central. Os próximos três parâmetros são a largura e a altura do pixel do bitmap a ser criado e um número máximo de iterações para o cálculo recursivo. O progress
parâmetro é usado para exibir o progresso desse cálculo. O cancelToken
parâmetro não é usado neste programa:
static class Mandelbrot
{
public static Task<BitmapInfo> CalculateAsync(Complex center,
double width, double height,
int pixelWidth, int pixelHeight,
int iterations,
IProgress<double> progress,
CancellationToken cancelToken)
{
return Task.Run(() =>
{
int[] iterationCounts = new int[pixelWidth * pixelHeight];
int index = 0;
for (int row = 0; row < pixelHeight; row++)
{
progress.Report((double)row / pixelHeight);
cancelToken.ThrowIfCancellationRequested();
double y = center.Imaginary + height / 2 - row * height / pixelHeight;
for (int col = 0; col < pixelWidth; col++)
{
double x = center.Real - width / 2 + col * width / pixelWidth;
Complex c = new Complex(x, y);
if ((c - new Complex(-1, 0)).Magnitude < 1.0 / 4)
{
iterationCounts[index++] = -1;
}
// http://www.reenigne.org/blog/algorithm-for-mandelbrot-cardioid/
else if (c.Magnitude * c.Magnitude * (8 * c.Magnitude * c.Magnitude - 3) < 3.0 / 32 - c.Real)
{
iterationCounts[index++] = -1;
}
else
{
Complex z = 0;
int iteration = 0;
do
{
z = z * z + c;
iteration++;
}
while (iteration < iterations && z.Magnitude < 2);
if (iteration == iterations)
{
iterationCounts[index++] = -1;
}
else
{
iterationCounts[index++] = iteration;
}
}
}
}
return new BitmapInfo(pixelWidth, pixelHeight, iterationCounts);
}, cancelToken);
}
}
O método retorna um objeto do tipo BitmapInfo
que fornece informações para criar um bitmap:
class BitmapInfo
{
public BitmapInfo(int pixelWidth, int pixelHeight, int[] iterationCounts)
{
PixelWidth = pixelWidth;
PixelHeight = pixelHeight;
IterationCounts = iterationCounts;
}
public int PixelWidth { private set; get; }
public int PixelHeight { private set; get; }
public int[] IterationCounts { private set; get; }
}
O arquivo XAML da Animação de Mandelbrot inclui dois Label
modos de exibição, um ProgressBar
e um Button
, bem como o SKCanvasView
:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
x:Class="MandelAnima.MainPage"
Title="Mandelbrot Animation">
<StackLayout>
<Label x:Name="statusLabel"
HorizontalTextAlignment="Center" />
<ProgressBar x:Name="progressBar" />
<skia:SKCanvasView x:Name="canvasView"
VerticalOptions="FillAndExpand"
PaintSurface="OnCanvasViewPaintSurface" />
<StackLayout Orientation="Horizontal"
Padding="5">
<Label x:Name="storageLabel"
VerticalOptions="Center" />
<Button x:Name="deleteButton"
Text="Delete All"
HorizontalOptions="EndAndExpand"
Clicked="OnDeleteButtonClicked" />
</StackLayout>
</StackLayout>
</ContentPage>
O arquivo code-behind começa definindo três constantes cruciais e uma matriz de bitmaps:
public partial class MainPage : ContentPage
{
const int COUNT = 10; // The number of bitmaps in the animation.
// This can go up to 50!
const int BITMAP_SIZE = 1000; // Program uses square bitmaps exclusively
// Uncomment just one of these, or define your own
static readonly Complex center = new Complex(-1.17651152924355, 0.298520986549558);
// static readonly Complex center = new Complex(-0.774693089457127, 0.124226621261617);
// static readonly Complex center = new Complex(-0.556624880053304, 0.634696788141351);
SKBitmap[] bitmaps = new SKBitmap[COUNT]; // array of bitmaps
···
}
Em algum momento, você provavelmente desejará alterar o COUNT
valor para 50 para ver todo o alcance da animação. Valores acima de 50 não são úteis. Em torno de um nível de zoom de 48 ou mais, a resolução de números de ponto flutuante de precisão dupla torna-se insuficiente para o cálculo do Conjunto de Mandelbrot. Esse problema é discutido na página 684 de Criando aplicativos móveis com Xamarin.Formso .
O center
valor é muito importante. Este é o foco do zoom de animação. Os três valores no arquivo são aqueles usados nas três capturas de tela finais no Capítulo 20 de Criando aplicativos móveis com Xamarin.Forms na página 684, mas você pode experimentar o programa nesse capítulo para criar um de seus próprios valores.
O exemplo de animação de Mandelbrot armazena esses COUNT
bitmaps no armazenamento de aplicativos locais. Cinquenta bitmaps exigem mais de 20 megabytes de armazenamento em seu dispositivo, então você pode querer saber quanto armazenamento esses bitmaps estão ocupando e, em algum momento, talvez queira excluí-los todos. Esse é o objetivo desses dois métodos na parte inferior da MainPage
classe:
public partial class MainPage : ContentPage
{
···
void TallyBitmapSizes()
{
long fileSize = 0;
foreach (string filename in Directory.EnumerateFiles(FolderPath()))
{
fileSize += new FileInfo(filename).Length;
}
storageLabel.Text = $"Total storage: {fileSize:N0} bytes";
}
void OnDeleteButtonClicked(object sender, EventArgs args)
{
foreach (string filepath in Directory.EnumerateFiles(FolderPath()))
{
File.Delete(filepath);
}
TallyBitmapSizes();
}
}
Você pode excluir os bitmaps no armazenamento local enquanto o programa estiver animando esses mesmos bitmaps porque o programa os retém na memória. Mas da próxima vez que você executar o programa, ele precisará recriar os bitmaps.
Os bitmaps armazenados no armazenamento de aplicativos locais incorporam o center
valor em seus nomes de arquivo, portanto, se você alterar a center
configuração, os bitmaps existentes não serão substituídos no armazenamento e continuarão a ocupar espaço.
Aqui estão os métodos que MainPage
usa para construir os nomes de arquivos, bem como um MakePixel
método para definir um valor de pixel com base em componentes de cor:
public partial class MainPage : ContentPage
{
···
// File path for storing each bitmap in local storage
string FolderPath() =>
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string FilePath(int zoomLevel) =>
Path.Combine(FolderPath(),
String.Format("R{0}I{1}Z{2:D2}.png", center.Real, center.Imaginary, zoomLevel));
// Form bitmap pixel for Rgba8888 format
uint MakePixel(byte alpha, byte red, byte green, byte blue) =>
(uint)((alpha << 24) | (blue << 16) | (green << 8) | red);
···
}
O zoomLevel
parâmetro a FilePath
varia de 0 à COUNT
constante menos 1.
O MainPage
construtor chama o LoadAndStartAnimation
método:
public partial class MainPage : ContentPage
{
···
public MainPage()
{
InitializeComponent();
LoadAndStartAnimation();
}
···
}
O LoadAndStartAnimation
método é responsável por acessar o armazenamento local do aplicativo para carregar quaisquer bitmaps que possam ter sido criados quando o programa foi executado anteriormente. Ele faz um loop através zoomLevel
de valores de 0 a COUNT
. Se o arquivo existir, ele o carregará na bitmaps
matriz. Caso contrário, ele precisará criar um bitmap para o particular center
e zoomLevel
valores chamando Mandelbrot.CalculateAsync
. Esse método obtém as contagens de iteração para cada pixel, que esse método converte em cores:
public partial class MainPage : ContentPage
{
···
async void LoadAndStartAnimation()
{
// Show total bitmap storage
TallyBitmapSizes();
// Create progressReporter for async operation
Progress<double> progressReporter =
new Progress<double>((double progress) => progressBar.Progress = progress);
// Create (unused) CancellationTokenSource for async operation
CancellationTokenSource cancelTokenSource = new CancellationTokenSource();
// Loop through all the zoom levels
for (int zoomLevel = 0; zoomLevel < COUNT; zoomLevel++)
{
// If the file exists, load it
if (File.Exists(FilePath(zoomLevel)))
{
statusLabel.Text = $"Loading bitmap for zoom level {zoomLevel}";
using (Stream stream = File.OpenRead(FilePath(zoomLevel)))
{
bitmaps[zoomLevel] = SKBitmap.Decode(stream);
}
}
// Otherwise, create a new bitmap
else
{
statusLabel.Text = $"Creating bitmap for zoom level {zoomLevel}";
CancellationToken cancelToken = cancelTokenSource.Token;
// Do the (generally lengthy) Mandelbrot calculation
BitmapInfo bitmapInfo =
await Mandelbrot.CalculateAsync(center,
4 / Math.Pow(2, zoomLevel),
4 / Math.Pow(2, zoomLevel),
BITMAP_SIZE, BITMAP_SIZE,
(int)Math.Pow(2, 10), progressReporter, cancelToken);
// Create bitmap & get pointer to the pixel bits
SKBitmap bitmap = new SKBitmap(BITMAP_SIZE, BITMAP_SIZE, SKColorType.Rgba8888, SKAlphaType.Opaque);
IntPtr basePtr = bitmap.GetPixels();
// Set pixel bits to color based on iteration count
for (int row = 0; row < bitmap.Width; row++)
for (int col = 0; col < bitmap.Height; col++)
{
int iterationCount = bitmapInfo.IterationCounts[row * bitmap.Width + col];
uint pixel = 0xFF000000; // black
if (iterationCount != -1)
{
double proportion = (iterationCount / 32.0) % 1;
byte red = 0, green = 0, blue = 0;
if (proportion < 0.5)
{
red = (byte)(255 * (1 - 2 * proportion));
blue = (byte)(255 * 2 * proportion);
}
else
{
proportion = 2 * (proportion - 0.5);
green = (byte)(255 * proportion);
blue = (byte)(255 * (1 - proportion));
}
pixel = MakePixel(0xFF, red, green, blue);
}
// Calculate pointer to pixel
IntPtr pixelPtr = basePtr + 4 * (row * bitmap.Width + col);
unsafe // requires compiling with unsafe flag
{
*(uint*)pixelPtr.ToPointer() = pixel;
}
}
// Save as PNG file
SKData data = SKImage.FromBitmap(bitmap).Encode();
try
{
File.WriteAllBytes(FilePath(zoomLevel), data.ToArray());
}
catch
{
// Probably out of space, but just ignore
}
// Store in array
bitmaps[zoomLevel] = bitmap;
// Show new bitmap sizes
TallyBitmapSizes();
}
// Display the bitmap
bitmapIndex = zoomLevel;
canvasView.InvalidateSurface();
}
// Now start the animation
stopwatch.Start();
Device.StartTimer(TimeSpan.FromMilliseconds(16), OnTimerTick);
}
···
}
Observe que o programa armazena esses bitmaps no armazenamento de aplicativos local em vez de na biblioteca de fotos do dispositivo. A biblioteca do .NET Standard 2.0 permite usar os métodos e familiares File.OpenRead
para File.WriteAllBytes
essa tarefa.
Depois que todos os bitmaps tiverem sido criados ou carregados na memória, o método inicia um Stopwatch
objeto e chama Device.StartTimer
. O OnTimerTick
método é chamado a cada 16 milissegundos.
OnTimerTick
Calcula um time
valor em milissegundos que varia de 0 a 6000 vezes COUNT
, que distribui seis segundos para a exibição de cada bitmap. O progress
valor usa o Math.Sin
valor para criar uma animação senoidal que será mais lenta no início do ciclo e mais lenta no final, pois inverte a direção.
O progress
valor varia de 0 a COUNT
. Isso significa que a parte inteira de progress
é um índice na bitmaps
matriz, enquanto a parte fracionária de indica um nível de progress
zoom para esse bitmap específico. Esses valores são armazenados nos bitmapIndex
campos e bitmapProgress
e exibidos pelo Label
e Slider
no arquivo XAML. O SKCanvasView
é invalidado para atualizar a exibição de bitmap:
public partial class MainPage : ContentPage
{
···
Stopwatch stopwatch = new Stopwatch(); // for the animation
int bitmapIndex;
double bitmapProgress = 0;
···
bool OnTimerTick()
{
int cycle = 6000 * COUNT; // total cycle length in milliseconds
// Time in milliseconds from 0 to cycle
int time = (int)(stopwatch.ElapsedMilliseconds % cycle);
// Make it sinusoidal, including bitmap index and gradation between bitmaps
double progress = COUNT * 0.5 * (1 + Math.Sin(2 * Math.PI * time / cycle - Math.PI / 2));
// These are the field values that the PaintSurface handler uses
bitmapIndex = (int)progress;
bitmapProgress = progress - bitmapIndex;
// It doesn't often happen that we get up to COUNT, but an exception would be raised
if (bitmapIndex < COUNT)
{
// Show progress in UI
statusLabel.Text = $"Displaying bitmap for zoom level {bitmapIndex}";
progressBar.Progress = bitmapProgress;
// Update the canvas
canvasView.InvalidateSurface();
}
return true;
}
···
}
Finalmente, o PaintSurface
manipulador do calcula um retângulo de SKCanvasView
destino para exibir o bitmap o maior possível, mantendo a proporção. Um retângulo de origem é baseado no bitmapProgress
valor. O fraction
valor calculado aqui varia de 0 quando bitmapProgress
é 0 para exibir o bitmap inteiro a 0,25 quando bitmapProgress
é 1 para exibir metade da largura e altura do bitmap, efetivamente ampliando:
public partial class MainPage : ContentPage
{
···
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear();
if (bitmaps[bitmapIndex] != null)
{
// Determine destination rect as square in canvas
int dimension = Math.Min(info.Width, info.Height);
float x = (info.Width - dimension) / 2;
float y = (info.Height - dimension) / 2;
SKRect destRect = new SKRect(x, y, x + dimension, y + dimension);
// Calculate source rectangle based on fraction:
// bitmapProgress == 0: full bitmap
// bitmapProgress == 1: half of length and width of bitmap
float fraction = 0.5f * (1 - (float)Math.Pow(2, -bitmapProgress));
SKBitmap bitmap = bitmaps[bitmapIndex];
int width = bitmap.Width;
int height = bitmap.Height;
SKRect sourceRect = new SKRect(fraction * width, fraction * height,
(1 - fraction) * width, (1 - fraction) * height);
// Display the bitmap
canvas.DrawBitmap(bitmap, sourceRect, destRect);
}
}
···
}
Este é o programa em execução:
Animação GIF
A especificação GIF (Graphics Interchange Format) inclui um recurso que permite que um único arquivo GIF contenha vários quadros sequenciais de uma cena que podem ser exibidos em sucessão, geralmente em um loop. Esses arquivos são conhecidos como GIFs animados. Os navegadores da Web podem reproduzir GIFs animados e o SkiaSharp permite que um aplicativo extraia os quadros de um arquivo GIF animado e os exiba sequencialmente.
O exemplo inclui um recurso GIF animado chamado Newtons_cradle_animation_book_2.gif criado por DemonDeLuxe e baixado da página Newton's Cradle na Wikipedia. A página GIF animado inclui um arquivo XAML que fornece essas informações e instancia um SKCanvasView
:
<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:skia="clr-namespace:SkiaSharp.Views.Forms;assembly=SkiaSharp.Views.Forms"
x:Class="SkiaSharpFormsDemos.Bitmaps.AnimatedGifPage"
Title="Animated GIF">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<skia:SKCanvasView x:Name="canvasView"
Grid.Row="0"
PaintSurface="OnCanvasViewPaintSurface" />
<Label Text="GIF file by DemonDeLuxe from Wikipedia Newton's Cradle page"
Grid.Row="1"
Margin="0, 5"
HorizontalTextAlignment="Center" />
</Grid>
</ContentPage>
O arquivo code-behind não é generalizado para reproduzir qualquer arquivo GIF animado. Ele ignora algumas das informações disponíveis, em particular uma contagem de repetição, e simplesmente reproduz o GIF animado em um loop.
O uso de SkisSharp para extrair os quadros de um arquivo GIF animado não parece estar documentado em nenhum lugar, então a descrição do código a seguir é mais detalhada do que o normal:
A decodificação do arquivo GIF animado ocorre no construtor da página e requer que o Stream
objeto que faz referência ao bitmap seja usado para criar um SKManagedStream
objeto e, em seguida, um SKCodec
objeto. A FrameCount
propriedade indica o número de quadros que compõem a animação.
Esses quadros são eventualmente salvos como bitmaps individuais, então o construtor usa FrameCount
para alocar uma matriz de tipo SKBitmap
, bem como duas int
matrizes para a duração de cada quadro e (para facilitar a lógica de animação) as durações acumuladas.
A FrameInfo
propriedade de SKCodec
classe é uma matriz de SKCodecFrameInfo
valores, um para cada quadro, mas a única coisa que este programa tira dessa estrutura é o Duration
do quadro em milissegundos.
SKCodec
define uma propriedade chamada Info
de tipo SKImageInfo
, mas esse SKImageInfo
valor indica (pelo menos para esta imagem) que o tipo de cor é SKColorType.Index8
, o que significa que cada pixel é um índice em um tipo de cor. Para evitar se incomodar com tabelas de cores, o programa usa as Width
informações e Height
dessa estrutura para construir seu próprio valor de cor ImageInfo
completa. Cada um SKBitmap
é criado a partir disso.
O GetPixels
método de SKBitmap
retorna uma IntPtr
referência aos bits de pixel desse bitmap. Esses bits de pixel ainda não foram definidos. Isso IntPtr
é passado para um dos GetPixels
métodos de SKCodec
. Esse método copia o quadro do arquivo GIF para o espaço de memória referenciado pelo IntPtr
. O SKCodecOptions
construtor indica o número do quadro:
public partial class AnimatedGifPage : ContentPage
{
SKBitmap[] bitmaps;
int[] durations;
int[] accumulatedDurations;
int totalDuration;
···
public AnimatedGifPage ()
{
InitializeComponent ();
string resourceID = "SkiaSharpFormsDemos.Media.Newtons_cradle_animation_book_2.gif";
Assembly assembly = GetType().GetTypeInfo().Assembly;
using (Stream stream = assembly.GetManifestResourceStream(resourceID))
using (SKManagedStream skStream = new SKManagedStream(stream))
using (SKCodec codec = SKCodec.Create(skStream))
{
// Get frame count and allocate bitmaps
int frameCount = codec.FrameCount;
bitmaps = new SKBitmap[frameCount];
durations = new int[frameCount];
accumulatedDurations = new int[frameCount];
// Note: There's also a RepetitionCount property of SKCodec not used here
// Loop through the frames
for (int frame = 0; frame < frameCount; frame++)
{
// From the FrameInfo collection, get the duration of each frame
durations[frame] = codec.FrameInfo[frame].Duration;
// Create a full-color bitmap for each frame
SKImageInfo imageInfo = code.new SKImageInfo(codec.Info.Width, codec.Info.Height);
bitmaps[frame] = new SKBitmap(imageInfo);
// Get the address of the pixels in that bitmap
IntPtr pointer = bitmaps[frame].GetPixels();
// Create an SKCodecOptions value to specify the frame
SKCodecOptions codecOptions = new SKCodecOptions(frame, false);
// Copy pixels from the frame into the bitmap
codec.GetPixels(imageInfo, pointer, codecOptions);
}
// Sum up the total duration
for (int frame = 0; frame < durations.Length; frame++)
{
totalDuration += durations[frame];
}
// Calculate the accumulated durations
for (int frame = 0; frame < durations.Length; frame++)
{
accumulatedDurations[frame] = durations[frame] +
(frame == 0 ? 0 : accumulatedDurations[frame - 1]);
}
}
}
···
}
Apesar do IntPtr
valor, nenhum unsafe
código é necessário porque o IntPtr
nunca é convertido em um valor de ponteiro C#.
Depois que cada quadro tiver sido extraído, o construtor totaliza as durações de todos os quadros e, em seguida, inicializa outra matriz com as durações acumuladas.
O restante do arquivo code-behind é dedicado à animação. O Device.StartTimer
método é usado para iniciar um temporizador em andamento, e o OnTimerTick
retorno de chamada usa um Stopwatch
objeto para determinar o tempo decorrido em milissegundos. Percorrer a matriz de durações acumuladas é suficiente para localizar o quadro atual:
public partial class AnimatedGifPage : ContentPage
{
SKBitmap[] bitmaps;
int[] durations;
int[] accumulatedDurations;
int totalDuration;
Stopwatch stopwatch = new Stopwatch();
bool isAnimating;
int currentFrame;
···
protected override void OnAppearing()
{
base.OnAppearing();
isAnimating = true;
stopwatch.Start();
Device.StartTimer(TimeSpan.FromMilliseconds(16), OnTimerTick);
}
protected override void OnDisappearing()
{
base.OnDisappearing();
stopwatch.Stop();
isAnimating = false;
}
bool OnTimerTick()
{
int msec = (int)(stopwatch.ElapsedMilliseconds % totalDuration);
int frame = 0;
// Find the frame based on the elapsed time
for (frame = 0; frame < accumulatedDurations.Length; frame++)
{
if (msec < accumulatedDurations[frame])
{
break;
}
}
// Save in a field and invalidate the SKCanvasView.
if (currentFrame != frame)
{
currentFrame = frame;
canvasView.InvalidateSurface();
}
return isAnimating;
}
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear(SKColors.Black);
// Get the bitmap and center it
SKBitmap bitmap = bitmaps[currentFrame];
canvas.DrawBitmap(bitmap,info.Rect, BitmapStretch.Uniform);
}
}
Cada vez que a currentframe
variável é alterada, o é invalidado SKCanvasView
e o novo quadro é exibido:
Claro, você vai querer executar o programa sozinho para ver a animação.