Avalonia通过继承TemplatedControl完成跳转动画

我们知道一点,就是:

TemplatedControl 是一个无外观控件(lookless control),其视觉外观由 Template 属性定义。 TemplatedControl.cs:14-17 它直接继承自 Control 类。 TemplatedControl.cs:17

附加属性

IsTemplateFocusTarget - 附加属性**,用于指定模板中哪个元素应该接收焦点装饰器**。 TemplatedControl.cs:106-107 当控件获得焦点时,焦点装饰器会显示在标记了此属性的元素周围,而不是控件本身。 TemplatedControl.cs:280-284

备注

TemplatedControl 是许多 Avalonia 控件的基类,包括之前讨论的 ContentControl。 它提供了模板化控件的基础架构,使得控件的逻辑和外观可以完全分离。 所有模板子元素的 TemplatedParent 属性都会被设置为该控件实例。因此,他很适合用来当做动画组件等使用。

因此我们这里可以模仿sukiUi实现一个双缓冲的FadeIn FadeOut动画。

using System;
using System.Threading;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;

namespace SukiUI.Controls
{
    // TODO: This needs fairly significant work to make a bit more bomb proof
    // There are probably some more gains that can be made in terms of performance.
    // Unfortunately we're still bound by the arrange of controls having to happen on the main thread.
    public class SukiTransitioningContentControl : TemplatedControl
    {
        internal static readonly StyledProperty<object?> FirstBufferProperty =
            AvaloniaProperty.Register<SukiTransitioningContentControl, object?>(nameof(FirstBuffer));

        internal object? FirstBuffer
        {
            get => GetValue(FirstBufferProperty);
            set => SetValue(FirstBufferProperty, value);
        }

        internal static readonly StyledProperty<object?> SecondBufferProperty =
            AvaloniaProperty.Register<SukiTransitioningContentControl, object?>(nameof(SecondBuffer));

        internal object? SecondBuffer
        {
            get => GetValue(SecondBufferProperty);
            set => SetValue(SecondBufferProperty, value);
        }

        public static readonly StyledProperty<object?> ContentProperty = AvaloniaProperty.Register<SukiTransitioningContentControl, object?>(nameof(Content));

        public object? Content
        {
            get => GetValue(ContentProperty);
            set => SetValue(ContentProperty, value);
        }

        private bool _isFirstBufferActive;

        private ContentPresenter? _firstBuffer = null;
        private ContentPresenter? _secondBuffer = null;

        private static readonly Animation FadeIn;
        private static readonly Animation FadeOut;
        
        private ContentPresenter? To => _isFirstBufferActive ? _firstBuffer : _secondBuffer;
        private ContentPresenter? From => _isFirstBufferActive ? _secondBuffer : _firstBuffer;

        private object? _contentBeforeApplied;

        static SukiTransitioningContentControl()
        {
            FadeIn = new Animation
            {
                Duration = TimeSpan.FromMilliseconds(400),
                Children =
                {
                    new KeyFrame()
                    {
                        Setters =
                        {
                            new Setter
                            {
                                Property = OpacityProperty,
                                Value = 0d
                            }
                        },
                        Cue = new Cue(0d)
                    },
                    new KeyFrame()
                    {
                        Setters =
                        {
                            new Setter
                            {
                                Property = OpacityProperty,
                                Value = 1d
                            }
                        },
                        Cue = new Cue(1d)
                    }
                },
                FillMode = FillMode.Forward
            };
            FadeOut = new Animation
            {
                Duration = TimeSpan.FromMilliseconds(400),
                Children =
                {
                    new KeyFrame()
                    {
                        Setters =
                        {
                            new Setter
                            {
                                Property = OpacityProperty,
                                Value = 1d
                            }
                        },
                        Cue = new Cue(0d)
                    },
                    new KeyFrame()
                    {
                        Setters =
                        {
                            new Setter
                            {
                                Property = OpacityProperty,
                                Value = 0d
                            }
                        },
                        Cue = new Cue(1d)
                    }
                },
                FillMode = FillMode.Forward
            };
            FadeIn.Duration = FadeOut.Duration = TimeSpan.FromMilliseconds(250);
        }

        private CancellationTokenSource _animCancellationToken = new();
        

        protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
        {
            base.OnPropertyChanged(change);
            if(change.Property == ContentProperty)
                PushContent(change.NewValue);
        }

        protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
        {
            base.OnApplyTemplate(e);
            if (e.NameScope.Get<ContentPresenter>("PART_FirstBufferControl") is { } fBuff)
                _firstBuffer = fBuff;
            if (e.NameScope.Get<ContentPresenter>("PART_SecondBufferControl") is { } sBuff)
                _secondBuffer = sBuff;
            if (_contentBeforeApplied != null)
            {
                PushContent(_contentBeforeApplied);
                _contentBeforeApplied = null;
            }
        }

        private void PushContent(object? content)
        {
            if (To is null || From is null)
            {
                _contentBeforeApplied = content;
                return;
            }
            
            _animCancellationToken.Cancel();
            _animCancellationToken.Dispose();
            _animCancellationToken = new CancellationTokenSource();
            
            if (_isFirstBufferActive) SecondBuffer = content;
            else FirstBuffer = content;
            _isFirstBufferActive = !_isFirstBufferActive;
            try
            {
                FadeOut.RunAsync(From, _animCancellationToken.Token).ContinueWith(_ =>
                {
                    Dispatcher.UIThread.Invoke(() =>
                    {
                        From.IsHitTestVisible = false;
                        if (_isFirstBufferActive) SecondBuffer = null;
                        else FirstBuffer = null;
                    });
                });
                FadeIn.RunAsync(To, _animCancellationToken.Token).ContinueWith(_ => 
                    Dispatcher.UIThread.Invoke(() => To.IsHitTestVisible = true));
            }
            catch
            {
                // ignored
            }
        }

        protected override void OnUnloaded(RoutedEventArgs e)
        {
            base.OnUnloaded(e);
            _animCancellationToken.Dispose();
        }
    }
}

我们注册了三个属性,其中有两个私有属性来当做缓冲器,一个Content属性来当做渲染的子组件内容。

我们重载了OnApplyTemplate,当模板应用完成了初始化之后就会自动调用,他会自动获取模板中的命名元素,建立控件与模板的连接,从 XAML 模板中获取两个 ContentPresenter 控件

  • PART_FirstBufferControl 和 PART_SecondBufferControl 是在 XAML 中定义的名称
  • 使用模式匹配 is { } 确保获取成功

之后他会调用调用PushContent去处理延迟的内容设置,然后再来讲一下为什么PushContent可以处理延迟:

if (To is null || From is null)
{
    _contentBeforeApplied = content;
    return;
}

我们使用了前置检查,检查缓冲区是否已经初始化,而To和From是活跃和非活跃的ContentPresenter,如果缓冲区没有准备好,就会将内容暂存到_contentBeforeApplied。

        _animationCancellationToken.Cancel();
        _animationCancellationToken.Dispose();
        _animationCancellationToken = new CancellationTokenSource();

这段代码是确保没有并发的动画,取消正在进行的动画,释放旧的取消令牌,然后创建新的令牌用于当前动画,这一段我们会自己讲述。

然后是缓冲区切换的逻辑:

if (_isFirstBufferActive) SecondBuffer = content;
else FirstBuffer = content;
_isFirstBufferActive = !_isFirstBufferActive;

这样To和From会自动指向正确的缓冲区。接下来是我们设置的动画:

FadeOut.RunAsync(From, _animCancellationToken.Token).ContinueWith(_ =>
{
    Dispatcher.UIThread.Invoke(() =>
    {
        From.IsHitTestVisible = false;
        if (_isFirstBufferActive) SecondBuffer = null;
        else FirstBuffer = null;
    });
});

这个作用是在旧内容上进行淡出动画,动画完成之后禁用旧内容的鼠标交互,清空缓冲区内容。

FadeIn.RunAsync(To, _animCancellationToken.Token).ContinueWith(_ => 
    Dispatcher.UIThread.Invoke(() => To.IsHitTestVisible = true));

然后是淡入动画,动画完成就会启用鼠标交互。

新内容到达 ↓ 检查缓冲区是否就绪 ↓ 取消之前的动画 ↓ 将新内容放入非活跃缓冲区 ↓ 切换活跃缓冲区标志 ↓ 同时启动淡出和淡入动画 ↓ 淡出完成 → 清理旧缓冲区 淡入完成 → 启用新内容交互

重载了属性改变的方法:


        protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
        {
            base.OnPropertyChanged(change);
            if(change.Property == ContentProperty)
                PushContent(change.NewValue);
        }

当属性改变之后,就会自动触发UI更新。

组件卸载的时候自动执行的重载方法:

        protected override void OnUnloaded(RoutedEventArgs e)
        {
            base.OnUnloaded(e);
            _animCancellationToken.Dispose();
        }
    }