SkiaSharp 中的基本动画

了解如何对 SkiaSharp 图形进行动画处理

通过定期调用 PaintSurface 方法,每次绘制略有不同的图形,可以在 Xamarin.Forms 中对 SkiaSharp 图形进行动画处理。 下面是本文后面显示的一个动画,其中包含似乎从中心展开的同心圆:

几个同心圆似乎从中心展开

示例程序中的“脉动椭圆”页面对椭圆的两个轴进行动画处理,使其看起来正在脉动,你甚至可以控制此脉动的速率PulsatingEllipsePage.xaml 文件实例化一个 Xamarin.FormsSlider 和一个 Label 以显示滑块的当前值。 这是与其他 Xamarin.Forms 视图集成 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.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>

代码隐藏文件实例化一个 Stopwatch 对象以充当高精度时钟。 OnAppearing 重写将 pageIsActive 字段设置为 true 并调用名为 AnimationLoop 的方法。 OnDisappearing 重写将 pageIsActive 字段设置为 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;
}

AnimationLoop 方法在 pageIsActivetrue 时启动 Stopwatch,然后循环。 这本质上是一个页面处于活动状态时会进行的“无限循环”,但它不会导致程序挂起,因为循环最终使用 await 运算符调用 Task.Delay,使得程序函数的其他部分可以正常工作。 Task.Delay 的参数导致它在 1/30 秒后完成。 这定义了动画的帧速率。

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();
}

while 循环首先从 Slider 中获取周期时间。 这是一个以秒为单位的时间,例如 5 秒。 第二个语句计算时间 t 的值。 如果 cycleTime 为 5,t 每 5 秒从 0 增加到 1。 第二个语句中 Math.Sin 函数的参数每 5 秒在 0 到 2π 之间变动。 Math.Sin 函数返回一个值,该值每 5 秒的变动规律为 0 -> 1 -> 0 -> -1 -> 0,但当数值接近 1 或 -1 时,值变化会较慢。 由于加上了值 1,因此值始终为正值,然后将其除以 2,因此值的变动规律为 1/2 -> 1 -> 1/2 -> 0 -> 1/2,但在值 1 和 0 附近变化会较慢。 这存储在 scale 字段中,并且 SKCanvasView 无效。

PaintSurface 方法使用此 scale 值计算椭圆的两个轴:

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);
    }
}

该方法根据显示区域的大小计算最大半径,以及基于最大半径的最小半径。 由于 scale 值在 0 -> 1 -> 0 之间的变动进行动画处理,因此该方法使用它来计算在 minRadiusmaxRadius 之间变动的 xRadiusyRadius。 这些值用于绘制和填充椭圆:

Pulsating Ellipse 页面的三张屏幕截图

请注意,SKPaint 对象是在 using 块中创建的。 与许多 SkiaSharp 类一样,SKPaint 派生自 SKObject,后者又派生自实现 IDisposable 接口的 SKNativeObjectSKPaint 替代 Dispose 方法以释放非托管资源。

SKPaint 放入 using 块可确保在该块末尾调用该 Dispose 以释放这些非托管资源。 虽然在 SKPaint 对象使用的内存被 .NET 垃圾回收器释放时,无论如何都会发生这种情况,但在动画代码中,最好以更有序的方式主动释放内存。

在这种特定情况下,更好的解决方法是一次创建两个 SKPaint 对象,并将其另存为字段。

这就是“扩展圆圈”动画的作用ExpandingCirclesPage 类首先定义多个字段,包括一个 SKPaint 对象:

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;
    }
    ...
}

此程序使用基于 Xamarin.FormsDevice.StartTimer 方法的另一种动画处理方法。 t 字段每 cycleTime 毫秒从 0 到 1 之间变动:

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;
    }
    ...
}

PaintSurface 处理程序使用动画弧度绘制五个同心圆。 如果 baseRadius 变量计算结果为 100,则 t 从 0 变动到 1 时,五个圆的弧度分别从 0 增加到 100,从 100 增加到 200,从 200 增加到 300,从 300 增加到 400,从 400 增加到 500。 对于大多数圆,strokeWidth 为 50,但对于第一个圆,strokeWidth 从 0 变为 50。 对于大多数圆,颜色为蓝色,但对于最后一个圆,颜色从蓝色变为透明。 请注意指定不透明度的 SKColor 构造函数的第四个参数:

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);
        }
    }
}

结果是,图像在 t 等于 0 时与 t 等于 1 时看起来相同,并且圆似乎会永远在不断扩展:

“展开圆圈”页的三张屏幕截图