Basic Animation in SkiaSharp
Discover how to animate your SkiaSharp graphics
You can animate SkiaSharp graphics in Xamarin.Forms by causing the PaintSurface
method to be called periodically, each time drawing the graphics a little differently. Here's an animation shown later in this article with concentric circles that seemingly expand from the center:
The Pulsating Ellipse page in the sample program animates the two axes of an ellipse so that it appears to be pulsating, and you can even control the rate of this pulsation. The PulsatingEllipsePage.xaml file instantiates a Xamarin.Forms Slider
and a Label
to display the current value of the slider. This is a common way to integrate an SKCanvasView
with other Xamarin.Forms views:
<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.PulsatingEllipsePage"
Title="Pulsating Ellipse">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Slider x:Name="slider"
Grid.Row="0"
Maximum="10"
Minimum="0.1"
Value="5"
Margin="20, 0" />
<Label Grid.Row="1"
Text="{Binding Source={x:Reference slider},
Path=Value,
StringFormat='Cycle time = {0:F1} seconds'}"
HorizontalTextAlignment="Center" />
<skia:SKCanvasView x:Name="canvasView"
Grid.Row="2"
PaintSurface="OnCanvasViewPaintSurface" />
</Grid>
</ContentPage>
The code-behind file instantiates a Stopwatch
object to serve as a high-precision clock. The OnAppearing
override sets the pageIsActive
field to true
and calls a method named AnimationLoop
. The OnDisappearing
override sets that pageIsActive
field to false
:
Stopwatch stopwatch = new Stopwatch();
bool pageIsActive;
float scale; // ranges from 0 to 1 to 0
public PulsatingEllipsePage()
{
InitializeComponent();
}
protected override void OnAppearing()
{
base.OnAppearing();
pageIsActive = true;
AnimationLoop();
}
protected override void OnDisappearing()
{
base.OnDisappearing();
pageIsActive = false;
}
The AnimationLoop
method starts the Stopwatch
and then loops while pageIsActive
is true
. This is essentially an "infinite loop" while the page is active, but it doesn't cause the program to hang because the loop concludes with a call to Task.Delay
with the await
operator, which lets other parts of the program function. The argument to Task.Delay
causes it to complete after 1/30th second. This defines the frame rate of the animation.
async Task AnimationLoop()
{
stopwatch.Start();
while (pageIsActive)
{
double cycleTime = slider.Value;
double t = stopwatch.Elapsed.TotalSeconds % cycleTime / cycleTime;
scale = (1 + (float)Math.Sin(2 * Math.PI * t)) / 2;
canvasView.InvalidateSurface();
await Task.Delay(TimeSpan.FromSeconds(1.0 / 30));
}
stopwatch.Stop();
}
The while
loop begins by obtaining a cycle time from the Slider
. This is a time in seconds, for example, 5. The second statement calculates a value of t
for time. For a cycleTime
of 5, t
increases from 0 to 1 every 5 seconds. The argument to the Math.Sin
function in the second statement ranges from 0 to 2π every 5 seconds. The Math.Sin
function returns a value ranging from 0 to 1 back to 0 and then to –1 and 0 every 5 seconds, but with values that change more slowly when the value is near 1 or –1. The value 1 is added so the values are always positive, and then it's divided by 2, so the values range from ½ to 1 to ½ to 0 to ½, but slower when the value is around 1 and 0. This is stored in the scale
field, and the SKCanvasView
is invalidated.
The PaintSurface
method uses this scale
value to calculate the two axes of the ellipse:
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear();
float maxRadius = 0.75f * Math.Min(info.Width, info.Height) / 2;
float minRadius = 0.25f * maxRadius;
float xRadius = minRadius * scale + maxRadius * (1 - scale);
float yRadius = maxRadius * scale + minRadius * (1 - scale);
using (SKPaint paint = new SKPaint())
{
paint.Style = SKPaintStyle.Stroke;
paint.Color = SKColors.Blue;
paint.StrokeWidth = 50;
canvas.DrawOval(info.Width / 2, info.Height / 2, xRadius, yRadius, paint);
paint.Style = SKPaintStyle.Fill;
paint.Color = SKColors.SkyBlue;
canvas.DrawOval(info.Width / 2, info.Height / 2, xRadius, yRadius, paint);
}
}
The method calculates a maximum radius based on the size of the display area, and a minimum radius based on the maximum radius. The scale
value is animated between 0 and 1 and back to 0, so the method uses that to compute an xRadius
and yRadius
that ranges between minRadius
and maxRadius
. These values are used to draw and fill an ellipse:
Notice that the SKPaint
object is created in a using
block. Like many SkiaSharp classes SKPaint
derives from SKObject
, which derives from SKNativeObject
, which implements the IDisposable
interface. SKPaint
overrides the Dispose
method to release unmanaged resources.
Putting SKPaint
in a using
block ensures that Dispose
is called at the end of the block to free these unmanaged resources. This happens anyway when memory used by the SKPaint
object is freed by the .NET garbage collector, but in animation code, it's best to be proactive in freeing memory in a more orderly way.
A better solution in this particular case would be to create two SKPaint
objects once and save them as fields.
That's what the Expanding Circles animation does. The ExpandingCirclesPage
class begins by defining several fields, including an SKPaint
object:
public class ExpandingCirclesPage : ContentPage
{
const double cycleTime = 1000; // in milliseconds
SKCanvasView canvasView;
Stopwatch stopwatch = new Stopwatch();
bool pageIsActive;
float t;
SKPaint paint = new SKPaint
{
Style = SKPaintStyle.Stroke
};
public ExpandingCirclesPage()
{
Title = "Expanding Circles";
canvasView = new SKCanvasView();
canvasView.PaintSurface += OnCanvasViewPaintSurface;
Content = canvasView;
}
...
}
This program uses a different approach to animation based on the Xamarin.Forms Device.StartTimer
method. The t
field is animated from 0 to 1 every cycleTime
milliseconds:
public class ExpandingCirclesPage : ContentPage
{
...
protected override void OnAppearing()
{
base.OnAppearing();
pageIsActive = true;
stopwatch.Start();
Device.StartTimer(TimeSpan.FromMilliseconds(33), () =>
{
t = (float)(stopwatch.Elapsed.TotalMilliseconds % cycleTime / cycleTime);
canvasView.InvalidateSurface();
if (!pageIsActive)
{
stopwatch.Stop();
}
return pageIsActive;
});
}
protected override void OnDisappearing()
{
base.OnDisappearing();
pageIsActive = false;
}
...
}
The PaintSurface
handler draws five concentric circles with animated radii. If the baseRadius
variable is calculated as 100, then as t
is animated from 0 to 1, the radii of the five circles increase from 0 to 100, 100 to 200, 200 to 300, 300 to 400, and 400 to 500. For most of the circles the strokeWidth
is 50 but for the first circle, the strokeWidth
animates from 0 to 50. For most of the circles, the color is blue, but for the last circle, the color is animated from blue to transparent. Notice the fourth argument to the SKColor
constructor that specifies the opacity:
public class ExpandingCirclesPage : ContentPage
{
...
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
SKImageInfo info = args.Info;
SKSurface surface = args.Surface;
SKCanvas canvas = surface.Canvas;
canvas.Clear();
SKPoint center = new SKPoint(info.Width / 2, info.Height / 2);
float baseRadius = Math.Min(info.Width, info.Height) / 12;
for (int circle = 0; circle < 5; circle++)
{
float radius = baseRadius * (circle + t);
paint.StrokeWidth = baseRadius / 2 * (circle == 0 ? t : 1);
paint.Color = new SKColor(0, 0, 255,
(byte)(255 * (circle == 4 ? (1 - t) : 1)));
canvas.DrawCircle(center.X, center.Y, radius, paint);
}
}
}
The result is that the image looks the same when t
equals 0 as when t
equals 1, and the circles seem to continue expanding forever: