Avalonia 制作复杂布局动画

截至本文撰写时,通过 “Avalonia 布局动画”“Avalonia 复杂动画” 等关键词仍很难在互联网上直接检索到 “如何使用 Avalonia 制作复杂的布局动画” 的相关内容。
恰好笔者在这方面有些研究,便想编撰一篇相对深入的文章。

这是笔者第一次面向互联网公开发表技术文章,如有错漏或用词不当,还请各位读者海涵,或直接指出。

一、简单了解 Avalonia 的动画系统

Avalonia 提供三种类型的动画:

类型 描述 用例
关键帧动画 使用多个关键帧在时间轴上改变一个或多个属性。 由样式选择器触发的复杂、多步骤动画。
控件过渡 在属性值变化时对单个属性进行动画处理。 为属性变化(不透明度、颜色、大小)提供平滑的视觉反馈。
组合动画 在渲染线程上运行的代码驱动动画。 从 C# 控制的高性能或程序化动画。

此外,页面过渡 还会在控件(如 TransitioningContentControlCarousel)中切换内容时产生动画。

更详细的内容不再赘述,如不了解请直接查阅 Avalonia 在线文档


二、布局动画的困局

二.1 布局动画六要素

一个控件在画布上的位置和尺寸由四个角的坐标与宽高决定,即:

WidthHeightCanvas.LeftCanvas.TopCanvas.RightCanvas.Bottom

可能你会想:Grid.RowDockPanel.Dock 等属性不也能起到类似作用吗?为什么没有把它们包含在内?
——因为它们对动画的支持非常有限,几乎无法用来实现平滑的布局动画。

二.2 布局系统简述

创建控件时并不强制要求提供上述“布局动画六要素”。
例如在 GridDockPanelStackPanel 等布局容器中,你只需要提供一些布局相关的附加信息,容器便会自行计算最终的布局结果。
这些计算结果最终都会反映到控件的 Bounds 属性上,而不是直接反映在 WidthHeightCanvas.Left 等属性上。

二.3 困局

最直观的想法往往是:直接对 WidthHeight 使用控件过渡就可以实现动画了。
然而一旦真正尝试,就会发现效果并不理想。很多人可能就卡在这一步,连如何控制 Canvas.LeftCanvas.TopCanvas.RightCanvas.Bottom 都还没来得及思考。

正如布局系统简述所说,布局动画六要素并非在所有情况下都是确定的。
从 C# 代码中创建动画固然是一个解决方案,但这会失去 .axaml 文件的灵活性——只要对 .axaml 的改动稍大一些,就必须同步修改创建动画的 C# 代码。
同时,Avalonia 框架动画系统默认提供的各种工具,很可能也需要你重新实现一遍。

这些因素使布局动画的开发成本和门槛都被抬高到了一个不合理的高度。


三、布局参考系(Layout Reference Frame)

三.1 概念

为动画目标引入一个参考对象动画目标容器
动画目标容器与动画目标的布局动画六要素可以从参考对象获取,或由同级的动画目标容器结合参考对象计算得出。其结构如下:

  • 根容器
    • 参考对象
    • 动画目标容器
      • 动画目标

下图是一张简略的导图:
布局参考系导图

三.2 设计思路

三.2.1 解构

既然我们的目的是获取布局动画六要素,那就可以先解构原先惯用的布局设计思路。
常见的 .axaml 文件通常利用各种布局控件来控制布局,例如:


    
        
            
            
        
        
        
    

这种设计可以拆解为两部分:布局数据业务内容。业务内容依赖布局数据来呈现。
基于这种解构,我们可以把上面的代码重新设计成如下形式:


    
        
            
                
            
            
                
                
                
            
            
            
        
        
            
                
                
                
            
            
            
            
            
        
    

这两段代码在布局呈现上效果完全相同,但重写之后我们获得了明确的布局动画六要素

三.2.2 动画目标容器

出于性能考虑,以及减少布局动画对文字排版等方面的负面影响,建议引入一个中间层 —— 动画目标容器。

最佳实践是:动画通常直接作用在动画目标容器上,而不是动画目标本身。

可以启用动画目标容器的 ClipToBounds="True",当容器的宽高发生变化时,会对动画目标产生遮罩裁剪的效果,以此来实现流畅的动画。
除非设计上确实需要,否则不要将动画直接作用在动画目标上。如果必须这么做,请至少权衡以下几点:

  • 如果动画目标内部包含大量子控件,布局计算的耗时可能会影响动画流畅度,严重时还会因 UI 线程阻塞导致应用卡顿。不过从 Avalonia V12 开始,渲染性能有指数级提升,这种瓶颈通常很难遇到。
  • 文字排版控件在宽高不足时可能发生换行,目前还没有特别高性能的文字动画方案。一般情况下,这种换行会影响动画的最终效果。
  • 如果动画直接作用在动画目标上,动画目标容器就变得非必要了。

三.2.3 动画目标

出于性能以及动画对文字布局的影响,宽高更新的发起者通常不应该是动画执行器本身,除非设计上有特殊要求。

三.2.4 参考目标

参考目标只负责提供布局动画六要素,不参与输入事件或命中测试,更不应将业务内容放置在参考目标中。

参考关系不一定总是动画目标向参考目标单向参考,比如上面解构中的示例。参考关系应根据实际提供布局动画六要素的主体灵活调整。


四、破局

引入布局参考系后,我们解决了 Avalonia 布局动画的最大障碍,获得了布局动画六要素
从 0 到 1 的跨越已经完成,接下来就是释放开发者创造力的时刻了。且让笔者先来当一回排头兵。

接下来的 Demo 中,我将尽量用简练的语言讲解设计思路。

四.1 什么样的 Demo ?

一个带侧边栏和主内容的应用,要求如下:

  • 侧边栏可以被隐藏,隐藏时要有动画过渡。
  • 主内容在侧边栏隐藏后需要占据原先侧边栏的区域,且要有动画过渡。
    img
    img_1

四.2 代码


    
        
        
    
    
        
            
            
        
        
        
            
                
                    
                        
                        
                        
                    
                
                
                    
                        
                            
                            
                        
                    
                    
                        
                            
                            
                        
                    
                    
                        
                            
                                
                                    
                                
                                
                                
                            
                        
                    
                
            

            
                
                    
                
                
                    
                
            

            
                
                    
                        
                    
                
                
                    
                        
                            
                            
                        
                        
                    
                
                
                    
                
                
                    
                        
                            
                                
                                    
                                    
                                
                                
                            
                            
                        
                    
                    
                        
                            
                            
                        
                    
                    
                        
                        
                    
                
            
        
    

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data.Converters;
using CommunityToolkit.Diagnostics;

namespace WCKYWCKF.Avalonia.Extension.Sample.Views;

public class AnimationValueConverter
{
    public static readonly AttachedProperty?> BeforeAnimationCacheProperty =
        AvaloniaProperty.RegisterAttached?>("BeforeAnimationCache");


    public static IMultiValueConverter GetWidthSum
    {
        get
        {
            return field ??= new FuncMultiValueConverter(Convert);


            object? Convert(IReadOnlyList arg)
            {
                return arg.Select(double (item) =>
                    {
                        return item switch
                        {
                            double value => value,
                            Thickness value => -(value.Left + value.Right),
                            _ => 0
                        };
                    })
                    .Sum();
            }
        }
    }

    public static IMultiValueConverter GetWidthNegate
    {
        get
        {
            return field ??= new FuncMultiValueConverter(Convert);


            object? Convert(IReadOnlyList arg)
            {
                Guard.IsTrue(arg.Count == 2);
                Guard.IsOfType(arg[0] ?? ThrowHelper.ThrowArgumentNullException());
                Guard.IsOfType(arg[1] ?? ThrowHelper.ThrowArgumentNullException());
                var countValue = (double)arg[0]!;
                var variableValue = (double)arg[1]!;
                return countValue - variableValue;
            }
        }
    }

    public static IMultiValueConverter GetWidthByIsVisible
    {
        get
        {
            return field ??= new FuncMultiValueConverter(Convert);


            object? Convert(IReadOnlyList arg)
            {
                Guard.IsTrue(arg.Count == 2);
                Guard.IsOfType(arg[0] ?? ThrowHelper.ThrowArgumentNullException());
                Guard.IsOfType(arg[1] ?? ThrowHelper.ThrowArgumentNullException());


                var countValue = (double)arg[0]!;
                var variableValue = (double)arg[1]!;
                return countValue > variableValue ? countValue : 0;
            }
        }
    }

    public static IMultiValueConverter GetValueBeforeAnimation
    {
        get
        {
            return field ??= new FuncMultiValueConverter(Convert);


            object? Convert(IReadOnlyList arg)
            {
                Guard.IsTrue(arg.Count == 4);
                var trigger = arg[0];
                var target = arg[2] as Control;
                var property = arg[3] as AvaloniaProperty;
                Guard.IsNotNull(target);
                Guard.IsNotNull(property);
                var value = target.GetValue(property);
                var cache = GetBeforeAnimationCache(target);
                if (cache is null)
                {
                    cache = new Dictionary();
                    SetBeforeAnimationCache(target, cache);
                }

                if (!cache.TryGetValue(property, out var cacheValue))
                {
                    cache[property] = new ValueBeforeAnimationCacheItem
                    {
                        CurrentValue = value
                    };
                }
                else
                {
                    if (!target.IsAnimating(property))
                    {
                        if (!CustomEquals(cacheValue.CurrentValue, value))
                        {
                            if (cacheValue.IsAnimating && !CustomEquals(cacheValue.TriggerValue, trigger))
                            {
                                cacheValue.CurrentValue = value;
                                cacheValue.OldValue = cacheValue.AnimatingValue;
                            }
                            else
                            {
                                cacheValue.CurrentValue = value;
                                cacheValue.AnimatingValue = cacheValue.OldValue;
                            }

                            cacheValue.TriggerValue = trigger;
                        }
                    }
                    else
                    {
                        cacheValue.AnimatingValue = value;
                        // if (property == Layoutable.WidthProperty)
                        //     cacheValue.DebugList.Add(((double)(cacheValue.OldValue ?? double.NaN), (double)(cacheValue.CurrentValue ?? double.NaN), (double)(cacheValue.AnimatingValue ?? double.NaN)));
                    }

                    return cacheValue.OldValue;
                }

                return value;
            }
        }
    }

    public static IMultiValueConverter GetWidthByMargin
    {
        get
        {
            return field ??= new FuncMultiValueConverter(Convert);

            double Convert(IReadOnlyList arg)
            {
                Guard.IsTrue(arg.Count == 2);
                Guard.IsTrue(arg[0] is double);
                var width = (double)arg[0]!;
                Guard.IsTrue(arg[1] is Thickness);
                var margin = (Thickness)arg[1]!;
                width -= margin.Left + margin.Right;
                width = Math.Max(0, width);
                return width;
            }
        }
    }

    public static IMultiValueConverter GetHeightByMargin
    {
        get
        {
            return field ??= new FuncMultiValueConverter(Convert);

            double Convert(IReadOnlyList arg)
            {
                Guard.IsTrue(arg.Count == 2);
                Guard.IsTrue(arg[0] is double);
                var height = (double)arg[0]!;
                Guard.IsTrue(arg[1] is Thickness);
                var margin = (Thickness)arg[1]!;
                height -= margin.Top + margin.Bottom;
                height = Math.Max(0, height);
                return height;
            }
        }
    }
}

四.3 讲解

对于 .axaml 部分的讲解我会直接引用对应的局部代码或控件名称;C# 部分则会直接引用方法名。

四.3.1 为什么使用控件过渡(Control Transitions)而不是关键帧动画(Keyframe Animations)

关键在于动画被打断后的接续。使用关键帧动画意味着你需要自己处理动画的起始值,这并不简单,而且相当麻烦。

对 Avalonia 了解不够深入或经验不足的开发者,经常会有这样的直觉:

“这样写就能拿到动画的起始值”:


    
        
    

但实际上这行不通,因为这样会让动画的首个值不断变化。
最直观的影响就是动画会变得很奇怪,尤其是在设置了缓动函数时,整体效果会像弹簧一样。

控件过渡则自动处理了这些问题(具体实现细节这里不展开)。
总而言之,它让动画即使在中途被打断,也能非常流畅地与下一个动画衔接,而不会让控件在起点和终点之间闪来闪去(写过关键帧动画的开发者大多都见过这种闪动)。

四.3.2 MultiBinding 下居然还可以套 MultiBinding?

可以的,MultiBinding 本身就继承自 BindingBase。通过嵌套 MultiBinding 并配合值转换器,可以实现许多巧妙的用法。

四.3.3 为什么绑定到控件时的写法是 ElementName 而不是 #Name

只是因为这样写 Rider 会有智能提示,不用手动输入完整名称。

四.3.4 在控制 SidebarLayer1 的可见性时为什么要用关键帧动画?

先理解 SidebarLayer1 可见性变化的需求:

  • 打开时:动画一开始就应当可见。
  • 关闭时:动画完全结束后才变为不可见。

要实现这样的时序控制,我们需要对属性设置的时机进行精细操作。
然而 Avalonia 并没有直接提供“动画结束后回调”之类的机制。在代码里控制当然可行,但那就偏离了本文的主题,失去了 .axaml 的灵活性。

在讲解本段之前,需要先了解 Avalonia 的属性系统和样式系统中一个关键点——样式化属性的设置是有优先级的(详见):

Animation = -1, // Highest priority
LocalValue = 0,
StyleTrigger,
Template,
Style,
Inherited,
Unset = int.MaxValue, // Lowest priority

还有一个动画系统的知识:bool 类型的属性也可以参与动画。

从上面可以看出,动画执行器设置的值优先级最高。同时,属性系统只会使用优先级最高的值。
因此,为了实现“关闭时:动画结束后才不可见”,我们可以利用关键帧动画另辟蹊径,就像这样:


    
        
            
        
        
            
        
    


四.3.5 为什么用


原文地址: https://www.cveoy.top/t/topic/qGLP 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录