Avalonia 制作复杂布局动画

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 文件通常利用各种布局控件来控制布局,例如:

xaml 复制代码
<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
             x:Class="WCKYWCKF.Avalonia.Extension.Sample.Views.UserControl1">
    <Grid RowDefinitions="Auto,*" ColumnDefinitions="*,*">
        <DockPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2">
            <Button DockPanel.Dock="Right"></Button>
            <TextBlock DockPanel.Dock="Left" Text="WCKYWCKF.Avalonia.Extension.Sample.Views.UserControl1"></TextBlock>
        </DockPanel>
        <TextBox Grid.Row="1" Grid.Column="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch"></TextBox>
        <Image Grid.Row="1" Grid.Column="1" Source="../Assets/启动时背景图.png"></Image>
    </Grid>
</UserControl>

这种设计可以拆解为两部分:布局数据业务内容 。业务内容依赖布局数据来呈现。

基于这种解构,我们可以把上面的代码重新设计成如下形式:

xaml 复制代码
<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
             x:Class="WCKYWCKF.Avalonia.Extension.Sample.Views.UserControl2">
    <Panel>
        <Grid RowDefinitions="Auto,*" ColumnDefinitions="*,*" IsHitTestVisible="False">
            <Grid.Styles>
                <Style Selector="Control">
                    <Setter Property="IsHitTestVisible" Value="False"></Setter>
                </Style>
            </Grid.Styles>
            <DockPanel Name="LRF_DockPanel" Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2">
                <Control Name="LRF_Button" DockPanel.Dock="Right"
                         Width="{Binding ElementName=ButtonLayer,Path=Bounds.Width}"
                         Height="{Binding ElementName=ButtonLayer,Path=Bounds.Height}">
                </Control>
                <Control Name="LRF_TextBlock" DockPanel.Dock="Left"></Control>
            </DockPanel>
            <Control Name="LRF_TextBox" Grid.Row="1" Grid.Column="0"></Control>
            <Control Name="LRF_Image" Grid.Row="1" Grid.Column="1"></Control>
        </Grid>
        <Canvas ZIndex="10">
            <Canvas Canvas.Left="{Binding ElementName=LRF_DockPanel,Path=Bounds.Left}"
                    Canvas.Top="{Binding ElementName=LRF_DockPanel,Path=Bounds.Top}"
                    Width="{Binding ElementName=LRF_DockPanel,Path=Bounds.Width}">
                <Button Name="ButtonLayer" Canvas.Left="{Binding ElementName=LRF_Button,Path=Bounds.Left}"
                        Canvas.Top="{Binding ElementName=LRF_Button,Path=Bounds.Top}">
                </Button>
                <TextBlock Name="TextBlockLayer" Canvas.Left="{Binding ElementName=LRF_TextBlock,Path=Bounds.Left}"
                           Canvas.Top="{Binding ElementName=LRF_TextBlock,Path=Bounds.Top}"
                           Width="{Binding ElementName=LRF_TextBlock,Path=Bounds.Width}"
                           Text="WCKYWCKF.Avalonia.Extension.Sample.Views.UserControl2">
                </TextBlock>
            </Canvas>
            <TextBox Canvas.Left="{Binding ElementName=LRF_TextBox,Path=Bounds.Left}"
                     Canvas.Top="{Binding ElementName=LRF_TextBox,Path=Bounds.Top}"
                     Width="{Binding ElementName=LRF_TextBox,Path=Bounds.Width}"
                     Height="{Binding ElementName=LRF_TextBox,Path=Bounds.Height}">
            </TextBox>
            <Image Canvas.Left="{Binding ElementName=LRF_Image,Path=Bounds.Left}"
                   Canvas.Top="{Binding ElementName=LRF_Image,Path=Bounds.Top}"
                   Width="{Binding ElementName=LRF_Image,Path=Bounds.Width}"
                   Height="{Binding ElementName=LRF_Image,Path=Bounds.Height}"
                   Source="../Assets/启动时背景图.png">
            </Image>
        </Canvas>
    </Panel>
</UserControl>

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

三.2.2 动画目标容器

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

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

可以启用动画目标容器的 ClipToBounds="True",当容器的宽高发生变化时,会对动画目标产生遮罩裁剪的效果,以此来实现流畅的动画。

除非设计上确实需要,否则不要将动画直接作用在动画目标上。如果必须这么做,请至少权衡以下几点:

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

三.2.3 动画目标

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

三.2.4 参考目标

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

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


四、破局

引入布局参考系后,我们解决了 Avalonia 布局动画的最大障碍,获得了布局动画六要素

从 0 到 1 的跨越已经完成,接下来就是释放开发者创造力的时刻了。且让笔者先来当一回排头兵。

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

四.1 什么样的 Demo ?

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

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

四.2 代码

xaml 复制代码
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:vm="using:WCKYWCKF.Avalonia.Extension.Sample.ViewModels"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:views="clr-namespace:WCKYWCKF.Avalonia.Extension.Sample.Views"
        xmlns:u="https://irihi.tech/ursa"
        xmlns:iconpark="https://irihi.tech/iconica/iconpark"
        mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="450"
        x:Class="WCKYWCKF.Avalonia.Extension.Sample.Views.MainWindow"
        x:DataType="vm:MainWindowViewModel"
        Title="WCKYWCKF.Avalonia.Extension.Sample"
        WindowStartupLocation="CenterScreen"
        Background="{DynamicResource HomeBrush}">
    <Window.Styles>
        <Style Selector="Canvas#SidebarLayer1.IsOpen">
            <Setter Property="Opacity" Value="1.0" />
            <Setter Property="Width">
                <Binding Path="Bounds.Width" ElementName="LRF_Sidebar" />
            </Setter>
            <Setter Property="Height">
                <Binding Path="Bounds.Height" ElementName="LRF_Sidebar" />
            </Setter>
            <Setter Property="IsVisible" Value="True" />
        </Style>
        <Style Selector="Canvas#SidebarLayer1:not(.IsOpen)">
            <Setter Property="Opacity" Value="0.0" />
            <Setter Property="Width" Value="0.0" />
            <Setter Property="Height" Value="0.0" />
            <Style.Animations>
                <Animation Duration="0:0:0.8">
                    <KeyFrame Cue="0%">
                        <Setter Property="IsVisible" Value="True" />
                    </KeyFrame>
                    <KeyFrame Cue="100%">
                        <Setter Property="IsVisible" Value="False" />
                    </KeyFrame>
                </Animation>
            </Style.Animations>
            <Setter Property="IsVisible" Value="False" />
        </Style>
    </Window.Styles>
    <Panel>
        <Grid ColumnDefinitions="3*,7*">
            <Control Name="LRF_Sidebar" Grid.Column="0" />
            <Control Name="LRF_ArticleView" Grid.Column="1" />
        </Grid>
        <views:StartPage ZIndex="10" />
        <Canvas HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
            <Canvas Name="SidebarLayer1"
                    HorizontalAlignment="Left"
                    VerticalAlignment="Top"
                    ClipToBounds="True"
                    Classes.IsOpen="{Binding #ToggleSwitch.IsChecked}">
                <Canvas.Transitions>
                    <Transitions>
                        <DoubleTransition Property="{x:Static Canvas.OpacityProperty}" Duration="0:0:0.8" Easing="CubicEaseInOut" />
                        <DoubleTransition Property="{x:Static Canvas.WidthProperty}" Duration="0:0:0.8" Easing="CubicEaseInOut" />
                        <DoubleTransition Property="{x:Static Canvas.HeightProperty}" Duration="0:0:0.8" Easing="CubicEaseInOut" />
                    </Transitions>
                </Canvas.Transitions>
                <Border Name="SidebarLayer2"
                        Canvas.Left="0"
                        Canvas.Top="0"
                        Classes="Shadow"
                        Theme="{DynamicResource CardBorder}"
                        HorizontalAlignment="Left"
                        VerticalAlignment="Stretch">
                    <Border.Width>
                        <MultiBinding Converter="{x:Static views:AnimationValueConverter.GetWidthByMargin}">
                            <Binding Path="Bounds.Width" ElementName="LRF_Sidebar" />
                            <Binding Path="Margin" ElementName="SidebarLayer2" />
                        </MultiBinding>
                    </Border.Width>
                    <Border.Height>
                        <MultiBinding Converter="{x:Static views:AnimationValueConverter.GetHeightByMargin}">
                            <Binding Path="Bounds.Height" ElementName="LRF_Sidebar" />
                            <Binding Path="Margin" ElementName="SidebarLayer2" />
                        </MultiBinding>
                    </Border.Height>
                    <u:NavMenu HorizontalAlignment="Stretch">
                        <u:NavMenu.Header>
                            <StackPanel VerticalAlignment="Stretch">
                                <StackPanel.Styles>
                                    <Style Selector="TextBlock">
                                        <Setter Property="Theme" Value="{DynamicResource TitleTextBlock}" />
                                        <Setter Property="TextWrapping" Value="Wrap" />
                                        <Setter Property="HorizontalAlignment" Value="Center" />
                                    </Style>
                                </StackPanel.Styles>
                                <TextBlock
                                    Classes="H1"
                                    Text="WCKYWCKF" />
                                <TextBlock
                                    Classes="H5"
                                    Text="Avalonia.Extension.Sample" />
                            </StackPanel>
                        </u:NavMenu.Header>
                    </u:NavMenu>
                </Border>
            </Canvas>

            <ToggleSwitch Name="ToggleSwitch"
                          IsChecked="True"
                          ZIndex="10"
                          Margin="{Binding #SidebarLayer2.Margin}"
                          CornerRadius="{Binding #SidebarLayer2.CornerRadius}"
                          Theme="{DynamicResource ButtonToggleSwitch}"
                          VerticalAlignment="Top"
                          HorizontalAlignment="Left"
                          VerticalContentAlignment="Center"
                          HorizontalContentAlignment="Center">
                <ToggleSwitch.OnContent>
                    <iconpark:MenuFoldOne />
                </ToggleSwitch.OnContent>
                <ToggleSwitch.OffContent>
                    <iconpark:MenuUnfoldOne />
                </ToggleSwitch.OffContent>
            </ToggleSwitch>

            <Canvas Name="ArticleViewLayer"
                    ClipToBounds="True"
                    Canvas.Left="{Binding ElementName=SidebarLayer1,Path=Width}">
                <Canvas.Transitions>
                    <Transitions>
                        <DoubleTransition Property="{x:Static Canvas.HeightProperty}" Duration="0:0:0.8" Easing="CubicEaseInOut" />
                    </Transitions>
                </Canvas.Transitions>
                <Canvas.Width>
                    <MultiBinding Converter="{x:Static views:AnimationValueConverter.GetWidthSum}">
                        <MultiBinding Converter="{x:Static views:AnimationValueConverter.GetWidthNegate}">
                            <Binding Path="Bounds.Width" ElementName="LRF_Sidebar" />
                            <Binding Path="Width" ElementName="SidebarLayer1" />
                        </MultiBinding>
                        <Binding Path="Bounds.Width" ElementName="LRF_ArticleView" />
                    </MultiBinding>
                </Canvas.Width>
                <Canvas.Height>
                    <Binding Path="Bounds.Height" ElementName="LRF_ArticleView" />
                </Canvas.Height>
                <Border Name="ArticleViewLayer2"
                        Classes="Shadow"
                        Theme="{DynamicResource CardBorder}"
                        Classes.IsOpen="{Binding #ToggleSwitch.IsChecked}">
                    <Border.Width>
                        <MultiBinding Converter="{x:Static views:AnimationValueConverter.GetWidthByMargin}">
                            <MultiBinding Converter="{x:Static views:AnimationValueConverter.GetWidthSum}">
                                <MultiBinding Converter="{x:Static views:AnimationValueConverter.GetWidthByIsVisible}">
                                    <Binding Path="Bounds.Width" ElementName="LRF_Sidebar" />
                                    <Binding Path="Width" ElementName="SidebarLayer1" />
                                </MultiBinding>
                                <Binding Path="Bounds.Width" ElementName="LRF_ArticleView" />
                            </MultiBinding>
                            <Binding Path="Margin" ElementName="ArticleViewLayer2" />
                        </MultiBinding>
                    </Border.Width>
                    <Border.Height>
                        <MultiBinding Converter="{x:Static views:AnimationValueConverter.GetHeightByMargin}">
                            <Binding Path="Bounds.Height" ElementName="LRF_ArticleView" />
                            <Binding Path="Margin" ElementName="ArticleViewLayer2" />
                        </MultiBinding>
                    </Border.Height>
                    <Grid RowDefinitions="*,*">
                        <views:UserControl1 Grid.Row="0"></views:UserControl1>
                        <views:UserControl2 Grid.Row="1"></views:UserControl2>
                    </Grid>
                </Border>
            </Canvas>
        </Canvas>
    </Panel>
</Window>
csharp 复制代码
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<IDictionary<AvaloniaProperty, ValueBeforeAnimationCacheItem>?> BeforeAnimationCacheProperty =
        AvaloniaProperty.RegisterAttached<AnimationValueConverter, Control, IDictionary<AvaloniaProperty, ValueBeforeAnimationCacheItem>?>("BeforeAnimationCache");


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


            object? Convert(IReadOnlyList<object?> 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<object,object?>(Convert);


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

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


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


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

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


            object? Convert(IReadOnlyList<object?> 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<AvaloniaProperty, ValueBeforeAnimationCacheItem>();
                    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<object,double>(Convert);

            double Convert(IReadOnlyList<object?> 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<object,double>(Convert);

            double Convert(IReadOnlyList<object?> 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 了解不够深入或经验不足的开发者,经常会有这样的直觉:

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

xaml 复制代码
<Animation Duration="0:0:0.8">
    <KeyFrame Cue="0%">
        <Setter Property="Height" Value="{Binding ElementName=LRF_Target,Path=Height}"></Setter>
    </KeyFrame>
</Animation>

但实际上这行不通,因为这样会让动画的首个值不断变化。

最直观的影响就是动画会变得很奇怪,尤其是在设置了缓动函数时,整体效果会像弹簧一样。

控件过渡则自动处理了这些问题(具体实现细节这里不展开)。

总而言之,它让动画即使在中途被打断,也能非常流畅地与下一个动画衔接,而不会让控件在起点和终点之间闪来闪去(写过关键帧动画的开发者大多都见过这种闪动)。

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

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

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

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

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

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

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

要实现这样的时序控制,我们需要对属性设置的时机进行精细操作。

然而 Avalonia 并没有直接提供"动画结束后回调"之类的机制。在代码里控制当然可行,但那就偏离了本文的主题,失去了 .axaml 的灵活性。

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

csharp 复制代码
Animation = -1, // Highest priority
LocalValue = 0,
StyleTrigger,
Template,
Style,
Inherited,
Unset = int.MaxValue, // Lowest priority

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

从上面可以看出,动画执行器设置的值优先级最高。同时,属性系统只会使用优先级最高的值。

因此,为了实现"关闭时:动画结束后才不可见",我们可以利用关键帧动画另辟蹊径,就像这样:

xaml 复制代码
<Style.Animations>
    <Animation Duration="0:0:0.8">
        <KeyFrame Cue="0%">
            <Setter Property="IsVisible" Value="True" />
        </KeyFrame>
        <KeyFrame Cue="100%">
            <Setter Property="IsVisible" Value="False" />
        </KeyFrame>
    </Animation>
</Style.Animations>
<Setter Property="IsVisible" Value="False" />

四.3.5 为什么用 <Style Selector="Canvas#SidebarLayer1:not(.IsOpen)"> 而不是 <Style Selector="Canvas#SidebarLayer1.IsClose">

当时我也尝试过直接写 <Style Selector="Canvas#SidebarLayer1.IsClose">,设想中这应该没有问题,但实际效果却事与愿违。

<Style Selector="Canvas#SidebarLayer1.IsOpen">.IsClose 中的 SetterAnimations 之间总会间隔较长的时间------动画和属性值没有被及时执行和设置。

对此笔者有一些推断(尚未通过研究 Avalonia 源码来验证):

xaml 复制代码
Classes.IsOpen="{Binding #ToggleSwitch.IsChecked}"
Classes.IsClose="{Binding !#ToggleSwitch.IsChecked}"
  • 根据 UI 线程的执行特点,上述两条绑定会先后执行。
  • 每次执行都会触发一轮样式系统和属性系统的更新,例如重新应用样式、设置属性等。
    • 检查 <Style Selector="Canvas#SidebarLayer1.IsOpen">
    • 检查 <Style Selector="Canvas#SidebarLayer1.IsClose">
  • 然后才会处理下一条绑定。

这意味着当 Canvas#SidebarLayer1.IsOpen 不成立时,.IsClose 并不会立刻成立,而是要等待下一轮样式和属性系统的更新,这对动画设计来说是灾难性的。

而只要使用 :not() 选择器------<Style Selector="Canvas#SidebarLayer1:not(.IsOpen)">------就可以保证在 .IsOpen 不成立时,样式立刻被命中,从而确保动画被及时执行。

因此,在所有类似的场景下,都应该优先使用 :not() 语法。

四.3.6 Demo 结语

其余部分并不难理解,Demo 中较为特殊的地方都已经解释过了。更多细节请直接阅读代码。

五、WCKYWCKF.Avalonia.Extension 仓库

这个仓库包含如下内容:

  • WCKYWCKF.Avalonia.Extension 一套拓展,当前有一套 Ursa.Avalonia 的 Form 的 MVVM 扩展并且仍在开发更多内容,笔者常用的内容都会加入到该仓库中。
  • WCKYWCKF.Avalonia.Extension.RxUI 一套 WCKYWCKF.Avalonia.Extension 的 ReactiveUI 实现。
  • WCKYWCKF.Avalonia.Animations 一套值转换器,用于布局动画。未来可能会开发一些自带布局动画的布局容器加入到该项目中。
  • WCKYWCKF.Avalonia.Extension.Sample 一套示例应用,内容有笔者写的技术文章、此仓库的内容展示与教程文档。

WCKYWCKF.Avalonia.Extension.Sample 项目的构建需要 Avalonia Pro License 和 铱泓科技的 Mantra 托管源的授权账号,求购请尝试通过以下方式联系:

Avalonia Pro License:Avalonia 官网

铱泓科技的 Mantra:社交媒体

六、结语

作为 Avalonia 中文社区的一员,不断壮大的 Avalonia 是我们共同的愿景。

对我个人而言,这第一篇技术文章只是第一步,所有因此文受益的人,都将迈出第二步、第三步......

七、版权

作者:望晨空忧

许可证:本文章采用 知识共享 署名---非商业性使用 4.0 国际版(CC BY-NC 4.0进行许可

笔者鼓励转载、改编为教学视频等有利于技术传播的行为,但请勿用于商业用途(例如将本文内容纳入付费教程),并请保留作者署名。

相关推荐
唐青枫6 小时前
C#.NET YARP 服务发现实战:接入 Consul 和 Kubernetes 动态发现后端服务
c#·.net
largecode6 小时前
座机号码认证如何操作?申请热线实名名片,树立统一官方客服形象
linux·sql·华为·c#·.net·wpf·harmonyos
小满Autumn9 小时前
WPF 入门:XAML 语法、布局与数据绑定
microsoft·c#·.net·wpf
光泽雨10 小时前
ADO.NET 进阶知识与实战坑位深度解析
性能优化·架构·.net
步步为营DotNet11 小时前
解密.NET 11:C# 14 在客户端响应式编程的突破与实践
microsoft·c#·.net
小满Autumn13 小时前
WPF 进阶:样式、触发器与控件模板
c#·.net·wpf
步步为营DotNet2 天前
深挖.NET 11:.NET Aspire 在云原生应用韧性架构构建的探索与实践
云原生·架构·.net
rick9772 天前
C# ModuleInitializer:程序集级别的初始化黑科技
.net
公子小六2 天前
基于.NET的Windows窗体编程之WinForms打印
windows·microsoft·c#·.net·winforms