Avalonia中的动画

作者:王先荣
内置动画 在Avalonia中,常用的动画有以下几种:关键帧动画(KeyFrame Animation)、过渡动画(Transition Animation)和合成动画(Composition Animations)。它们在使用方式和适用场景有所不同,官方文档的总结如下表所示(https://docs.avaloniaui.net/docs/graphics-animation/animations)。

|------------------------|------------------------------------------------------------------------|----------------------------------------------------------------------|
| Type | Description | Use case |
| Keyframe Animations | Change one or more properties over a timeline with multiple keyframes. | Complex, multi-step animations triggered by style selectors. |
| Control Transitions | Animate a single property when its value changes. | Smooth visual feedback for property changes (opacity, color, size). |
| Composition Animations | Code-driven animations that run on the render thread. | Performance-sensitive or programmatic animations controlled from C#. |

严格来说,它们都是插值动画(Interpolated Animations)。那么怎样才能实现离散动画呢?例如打字机效果的动画,游戏中的精灵动画。 Avalonia官方的自定义动画示例 Avalonia的官方示例项目(RenderDemo https://github.com/AvaloniaUI/Avalonia/tree/master/samples/RenderDemo)中,实现了一个名为CustomStringAnimator(https://github.com/AvaloniaUI/Avalonia/blob/master/samples/RenderDemo/Pages/CustomStringAnimator.cs)的动画类,它继承自InterpolatingAnimator\<string>,并重写了Interpolate方法来实现打字机效果的动画。以下是该动画类的代码:
public class CustomStringAnimator : InterpolatingAnimator<string> { public override string Interpolate(double progress, string oldValue, string newValue) { if (newValue.Length == 0) return ""; var step = 1.0 / newValue.Length; var length = (int)(progress / step); var result = newValue.Substring(0, length + 1); return result; } }
自定义动画

使用起来跟普通的动画几乎一样,需要指定Animator,代码如下(https://github.com/AvaloniaUI/Avalonia/blob/master/samples/RenderDemo/Pages/CustomAnimatorPage.xaml):
<TextBlock HorizontalAlignment="Center" VerticalAlignment="Center"> <TextBlock.Styles> <Style Selector="TextBlock"> <Style.Animations> <Animation Duration="0:0:1" IterationCount="Infinite"> <KeyFrame Cue="0%"> <Setter Property="Text" Value=""> <Animation.Animator> <pages:CustomStringAnimator/> </Animation.Animator> </Setter> </KeyFrame> <KeyFrame Cue="100%"> <Setter Property="Text" Value="0123456789" > <Animation.Animator> <pages:CustomStringAnimator/> </Animation.Animator> </Setter> </KeyFrame> </Animation> </Style.Animations> </Style> </TextBlock.Styles> </TextBlock>
View Code

值得注意的是在axaml中使用自定义动画器时,需要指定动画器的类型(<Animation.Animator><pages:CustomStringAnimator/></Animation.Animator>),否则会报错。 改进的字符串动画 在官方示例的基础上,我们可以略微做一些改进:支持在字符串之间进行插值动画。动画过程中会根据进度逐渐显示新字符串的内容,同时保留旧字符串的公共前缀部分。
public class StringAnimator : InterpolatingAnimator<string> { /// <summary> /// 计算插值结果。 /// </summary> /// <param name="progress">进度:0到1之间的值,指在两个关键帧之间过渡的时间位置。</param> /// <param name="oldValue">旧值:动画开始时,第一个关键帧的值。</param> /// <param name="newValue">新值:动画结束时,最后一个关键帧的值。</param> /// <returns>返回插值结果。</returns> public override string Interpolate(double progress, string oldValue, string newValue) { if (progress <= 0) return oldValue; if (progress >= 1) return newValue; int oldLength = string.IsNullOrEmpty(oldValue) ? 0 : oldValue.Length; int newLength = string.IsNullOrEmpty(newValue) ? 0 : newValue.Length; if (oldLength == 0 && newLength == 0) return string.Empty; if (oldLength == newLength && oldValue == newValue) return newValue; if (oldLength == 0 || newValue.StartsWith(oldValue)) { // 如果旧值为空或新值以旧值开头,则从旧值开始插入新值的剩余部分 return newValue.Substring(0, (int)(oldLength + (newLength - oldLength) * progress)); } else if (newLength == 0 || oldValue.StartsWith(newValue)) { // 如果新值为空或旧值以新值开头,则从新值开始删除旧值的剩余部分 return oldValue.Substring(0, (int)(newLength + (oldLength - newLength) * (1 - progress))); } else { //如果没有包含关系,忽略旧值,直接从新值开始插入 oldLength = 0; return newValue.Substring(0, (int)(oldLength + (newLength - oldLength) * progress)); } } static StringAnimator() { // 注册动画器 Animation.RegisterCustomAnimator<string, StringAnimator>(); } }

使用方法跟官方示例一样。 自定义离散动画 如果要实现通用的离散动画,该怎么实现呢?跟上面类似,我们可以从InterpolatingAnimator<T>继承一个DiscreteAnimator<T>类,代码很简单,放弃中间的过渡部分,直接在进度达到100%时切换到新值,代码如下:
public class DiscreteAnimator<T> : InterpolatingAnimator<T> { public override T Interpolate(double progress, T oldValue, T newValue) { return progress < 1 ? oldValue : newValue; } static DiscreteAnimator() { // 注册动画器 Animation.RegisterCustomAnimator<T, DiscreteAnimator<T>>(); } }

使用方法跟上面一样:
<TextBlock Text="离散动画器文本,切换几种不同的样式。"> <TextBlock.Styles> <Style Selector="TextBlock"> <Style.Animations> <Animation Duration="0:0:3" IterationCount="Infinite" PlaybackDirection="Alternate"> <KeyFrame Cue="0%"> <Setter Property="FontStyle" Value="Normal"> <Animation.Animator> <app:DiscreteAnimator x:TypeArguments="FontStyle" /> </Animation.Animator> </Setter> </KeyFrame> <KeyFrame Cue="30%"> <Setter Property="FontStyle" Value="Italic"> <Animation.Animator> <app:DiscreteAnimator x:TypeArguments="FontStyle" /> </Animation.Animator> </Setter> </KeyFrame> <KeyFrame Cue="60%"> <Setter Property="FontStyle" Value="Oblique"> <Animation.Animator> <app:DiscreteAnimator x:TypeArguments="FontStyle" /> </Animation.Animator> </Setter> </KeyFrame> </Animation> </Style.Animations> </Style> </TextBlock.Styles> </TextBlock>

因为我们使用了泛型,在指定动画器时需要使用x:TypeArguments来指定类型参数(<app:DiscreteAnimator x:TypeArguments="FontStyle" />),否则会报错。 另一种实现自定义动画的变通方法 如果不想实现通用的动画器,我们可以注册AvaloniaProperty,并在属性的PropertyChanged回调中直接修改属性值来实现动画效果。以下是一个简单的示例,演示如何通过注册一个名为DiscreteValue的AvaloniaProperty来实现几何路径变换的动画:
//注册一个AvaloniaProperty,类型为int,默认值为0 public const string DISCRETE_VALUE = "DiscreteValue"; public static readonly StyledProperty<int> DiscreteValueProperty = AvaloniaProperty.Register<Path, int>(DISCRETE_VALUE, 0); //定义一个数组,存储不同的几何路径,在动画过程中根据DiscreteValue的值来切换不同的路径 private PathGeometry[] bells = []; //这里省略了路径的定义 //创建一个动画并运行它 private void PlayAnimation_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { var animation = new Animation() { Duration = TimeSpan.FromSeconds(8), IterationCount = new IterationCount(2), PlaybackDirection = PlaybackDirection.Alternate, Children = { new KeyFrame() { Setters = { new Setter(DiscreteValueProperty, 0), }, KeyTime = TimeSpan.FromSeconds(0) }, new KeyFrame() { Setters = { new Setter(DiscreteValueProperty, 1), }, KeyTime = TimeSpan.FromSeconds(2) }, new KeyFrame() { Setters = { new Setter(DiscreteValueProperty, 2), }, KeyTime = TimeSpan.FromSeconds(4) }, new KeyFrame() { Setters = { new Setter(DiscreteValueProperty, 3), }, KeyTime = TimeSpan.FromSeconds(6) }, new KeyFrame() { Setters = { new Setter(DiscreteValueProperty, 3), }, KeyTime = TimeSpan.FromSeconds(8) } } }; _ = animation.RunAsync(Path1); } //在属性的PropertyChanged事件中,根据DiscreteValue的值来切换不同的路径 private void Path1_PropertyChanged(object? sender, Avalonia.AvaloniaPropertyChangedEventArgs e) { if (e.Property.Name == DiscreteValueProperty.Name) { Debug.WriteLine($"time: {DateTime.Now}, Path1 property changed: {e.Property.Name}, old value: {e.OldValue}, new value: {e.NewValue}"); int value = (int)e.NewValue; Path1.Data = bells[value % bells.Length]; } }

这种方法只能在代码中调用,无法在axaml中使用,但它不需要实现通用的动画器,适合一些特定的动画效果。 本文相关代码可以在我的Gitee仓库中找到: https://gitee.com/xrwang2/avalonia-test/tree/master/AvaloniaMvvpDesktopApp