Skip to content

WinUI 参考: 完整的属性清单与设计指南见 Motion

Microsoft.UI.Reactor(Reactor)提供了四套动画系统,以及一条关于如何挑选的规则。 先说规则:合成器只能动画五个属性 —— Opacity、Offset(Translation)、 Scale、Rotation 和 CenterPoint —— 而且一旦由它来动画,托管渲染线程就不参与, 动画直接在 GPU 上按屏幕刷新率运行。凡是动画这五个属性的东西(隐式过渡如 .OpacityTransition()、用于持久隐式动画的 .Animate()、用于事件作用域批处理的 .WithAnimation()、用于进入/退出的 .Transition()、用于悬停/按下/聚焦的 .InteractionStates()、用于多步骤的 .Keyframes()),底层都是同一条流水线, 只是人体工学不同。另外三套系统是为了突破这个上限而存在的:.LayoutAnimation() 动画由布局重排引起的位置变化(新增了兄弟元素、面板重新度量了), .ConnectedAnimation() 在元素卸载时拍下快照,并在挂载时把这个快照播放进 匹配的元素,而 WinUI 的 Storyboard(通过 .Set(...) 可达)覆盖上述所有 方式都碰不到的那些罕见属性。如果你正在 .Animate().WithAnimation() 之间犹豫,请先读合成器动画;如果你想把多个步骤串起来, 请读编排

动画

Reactor 的动画是声明式的。你设定目标值(不透明度、缩放、位移),再挂上一个 过渡修饰符。当这个值在下一次渲染中发生变化时 —— 由 hooks 与状态驱动 —— WinUI 会自动从旧值动画到新值。

参考表

API 动画对象 触发方式 何时使用
.OpacityTransition(duration?) Opacity 隐式,每次变化 显示/隐藏单个元素。
.ScaleTransition(transition?) Scale 隐式,每次变化 元素尺寸变化的反馈。
.TranslationTransition(transition?) Translation(偏移) 隐式,每次变化 滑动单个元素。
.RotationTransition(duration?) Rotation 隐式,每次变化 旋转单个元素。
.BackgroundTransition(duration?) 背景画笔 隐式;仅面板 Grid / Stack 上的颜色过渡。
.Animate(curve, props?) 任意合成器属性 隐式,持久 该元素的所有变化都用同一条曲线。
AnimationScope.WithAnimation(curve, action) 在 action 内部被修改的合成器属性 事件作用域 一次状态变更驱动一批动画。
.Transition(t, curve?) 进入 / 退出 元素进入或离开元素树时 动画 When(...) / 三元表达式的挂载与卸载。
.InteractionStates(builder, curve?) 各状态下的合成器属性 悬停 / 按下 / 聚焦 零协调次数的指针状态反馈。
.Keyframes(name, trigger, builder) 进度点上的合成器属性 trigger 变化时 多步骤动画(脉冲、抖动、呼吸)。
.Stagger(delay, curve?) 容器的子元素 兄弟级联 让列表上的进入/退出与布局动画级联展开。
.LayoutAnimation(duration?) / .SpringLayoutAnimation(...) 由布局重排决定的元素位置 布局过程 动画某个兄弟元素引起的位置变化。
.ConnectedAnimation(key) 从旧位置到新位置的快照 同一 key 卸载/挂载时 列表到详情页的主视觉动画。
AnimationScope.WithAnimationAsync(curve, action) 在 action 内部被修改的合成器属性 事件作用域,返回 Task await 串联多个步骤。

不透明度过渡

.OpacityTransition() 动画不透明度的变化。把 .Opacity() 设为你的目标值, 剩下的交给过渡处理:

class OpacityDemo : Component
{
    public override Element Render()
    {
        var (visible, setVisible) = UseState(true);

        return VStack(12,
            SubHeading("Opacity Transition"),
            Button(visible ? "Fade Out" : "Fade In",
                () => setVisible(!visible))
                .AutomationName(visible ? "Fade out text" : "Fade in text"),
            TextBlock("This text fades in and out")
                .FontSize(18).Bold()
                .Opacity(visible ? 1.0 : 0.0)
                .OpacityTransition(TimeSpan.FromMilliseconds(500))
        ).Padding(24);
    }
}

不透明度过渡

可选的 TimeSpan 参数控制时长,默认 300ms。显示与隐藏元素时的 淡入淡出就用它。

缩放过渡

.ScaleTransition() 动画缩放的变化。把 .Scale() 设为目标倍数:

class ScaleDemo : Component
{
    public override Element Render()
    {
        var (enlarged, setEnlarged) = UseState(false);

        return VStack(12,
            SubHeading("Scale Transition"),
            Button(enlarged ? "Shrink" : "Enlarge",
                () => setEnlarged(!enlarged))
                .AutomationName(enlarged ? "Shrink sample" : "Enlarge sample"),
            Border(
                TextBlock("Scales up and down").FontSize(18).Bold()
            ).Padding(12)
             .CornerRadius(8)
             .Background(Theme.CardBackground)
             .Scale(enlarged ? 1.5f : 1.0f)
             .ScaleTransition()
        ).Padding(24);
    }
}

缩放过渡

1.0f 是原始大小,1.5f 是 150%。默认从元素的左上角开始放大 —— 想围绕别的原点缩放就设置 .CenterPoint()。你也可以传入自定义的 Vector3Transition 来控制哪些轴参与动画。

位移过渡

.TranslationTransition() 动画位置偏移。把 .Translation() 设为目标的 X、Y、Z 偏移(单位为像素):

class TranslationDemo : Component
{
    public override Element Render()
    {
        var (moved, setMoved) = UseState(false);

        return VStack(12,
            SubHeading("Translation Transition"),
            Button(moved ? "Slide Back" : "Slide Right",
                () => setMoved(!moved))
                .AutomationName(moved ? "Slide sample back" : "Slide sample right"),
            TextBlock("Slides horizontally")
                .FontSize(18).Bold()
                .Translation(moved ? 120f : 0f, 0f, 0f)
                .TranslationTransition()
        ).Padding(24);
    }
}

位移过渡

位移偏移是相对于元素布局位置的。X 为正向右移动,Y 为正向下移动。 元素仍然占据它原本的布局空间 —— 只有视觉位置发生了变化。

背景过渡

.BackgroundTransition()VStackHStackGrid 元素上动画 背景颜色的变化:

class BackgroundDemo : Component
{
    public override Element Render()
    {
        var (warm, setWarm) = UseState(false);

        return VStack(12,
            SubHeading("Background Transition"),
            Button(warm ? "Cool Colors" : "Warm Colors",
                () => setWarm(!warm))
                .AutomationName(warm ? "Switch to cool colors" : "Switch to warm colors"),
            VStack(8,
                TextBlock("Background animates between colors")
                    .Foreground(Theme.AccentText).Bold()
            ).Padding(16)
             .CornerRadius(8)
             .Background(warm ? Theme.SystemCaution : Theme.Accent)
             .BackgroundTransition(TimeSpan.FromMilliseconds(600))
        ).Padding(24);
    }
}

背景过渡

背景过渡使用 WinUI 的 BrushTransition。它们只在面板元素 (StackPanelGrid)上生效,因为 WinUI 把 BackgroundTransition 限制在这几种类型上。

组合过渡

你可以在单个元素上串联多个过渡修饰符。每个属性各自独立地动画:

class CombinedDemo : Component
{
    public override Element Render()
    {
        var (active, setActive) = UseState(false);

        return VStack(12,
            SubHeading("Combined Transitions"),
            Button(active ? "Reset" : "Animate",
                () => setActive(!active))
                .AutomationName(active ? "Reset combined transitions" : "Run combined transitions"),
            Border(
                TextBlock("All at once").FontSize(16).Bold()
                    .Foreground(Theme.AccentText)
            ).Padding(16)
             .CornerRadius(8)
             .Background(Theme.Accent)
             .Opacity(active ? 1.0 : 0.4)
             .Scale(active ? 1.2f : 1.0f)
             .Translation(active ? 40f : 0f, 0f, 0f)
             .OpacityTransition(TimeSpan.FromMilliseconds(400))
             .ScaleTransition()
             .TranslationTransition()
        ).Padding(24);
    }
}

组合过渡

每个过渡修饰符彼此独立 —— .OpacityTransition() 动画不透明度的同时, .ScaleTransition() 也在动画缩放。设定目标值(.Opacity().Scale().Translation()),各个属性的过渡就会并行地把动画跑完。

布局动画

.LayoutAnimation() 在元素因布局重排而改变位置时播放动画 —— 比如 collection 里的项进入、离开或重排:

class LayoutAnimationDemo : Component
{
    public override Element Render()
    {
        var (items, updateItems) = UseReducer(
            new List<string> { "Apple", "Banana", "Cherry" });
        var nextId = UseRef(3);

        return VStack(12,
            SubHeading("Layout Animation"),
            HStack(8,
                Button("Add Item", () =>
                {
                    nextId.Current++;
                    updateItems(l => [$"Item {nextId.Current}", .. l]);
                }).AutomationName("Add layout animation item"),
                Button("Remove First", () =>
                    updateItems(l => l.Count > 0 ? l[1..] : l))
                    .AutomationName("Remove first layout animation item")
            ),
            VStack(4, items.Select(item =>
                Border(
                    TextBlock(item)
                ).Padding(horizontal: 8, vertical: 12)
                    .Background(Theme.CardBackground)
                    .CornerRadius(4)
                    .LayoutAnimation()
                    .WithKey($"item-{item}")
            ).ToArray())
        ).Padding(24);
    }
}

布局动画

布局动画工作在合成层。当 WinUI 重新摆放一个元素时(比如新增或移除了一个 兄弟元素),Reactor 会从旧位置动画到新位置。请给每个元素加上 .WithKey(), 这样协调器才能在重排时跟踪身份。

你也可以用 .LayoutAnimation(TimeSpan) 自定义时长,或用 .SpringLayoutAnimation() 获得回弹手感。

连接动画

.ConnectedAnimation(key) 在两个视图之间创造视觉连续性。当一个带 key 的 元素被卸载、而另一个带相同 key 的元素被挂载时,WinUI 会把快照从旧位置 动画到新位置:

class ConnectedAnimationDemo : Component
{
    public override Element Render()
    {
        var (selected, setSelected) = UseState<string?>(null);

        if (selected is not null)
            return VStack(12,
                Button("Back to list", () => setSelected(null))
                    .AutomationName("Return to animation list"),
                TextBlock(selected)
                    .FontSize(28).Bold()
                    .ConnectedAnimation($"title-{selected}")
            ).Padding(24);

        var items = new[] { "Photos", "Music", "Videos" };
        return VStack(12,
            SubHeading("Connected Animation"),
            VStack(4,
                items.Select(item =>
                    Button(item, () => setSelected(item))
                        .AutomationName($"Open {item}")
                        .ConnectedAnimation($"title-{item}")
                        .WithKey($"source-{item}")
                ).ToArray()
            )
        ).Padding(24);
    }
}

连接动画

源元素和目标元素必须使用相同的 key 字符串。当协调器检测到这个转换时, 动画会自动运行。源与目标必须出现在同一次渲染中 —— 协调器会在这一轮 协调过程中发布迁出元素的快照,并在同一轮结束时把它播放进迁入的元素。

Reactor 会给每一个带 key 的迁出元素拍快照,因为它无法知道你激活了 哪个兄弟元素。因此把一组带 key 的行折叠成一个详情元素时,未被选中的那些行 的快照会留下来,WinUI 会把它们各自绘制在旧位置上约一秒钟才过期。 如果这个重叠很碍眼,就把带 key 的集合控制得小一些 —— 只给真正在其间导航的 那些行加 key。

连接动画与同一元素上的退出 .Transition(...) 无法组合:退出过渡会推迟 该元素的拆除,连带着把快照也推迟过了目标元素寻找它的那个时点,于是动画 静默地不播放。请把 key 和退出过渡放在不同的元素上。

连接动画适用于列表到详情的导航场景 —— 某个元素从列表里 "飞"进详情视图。

事务性动画 —— Animations.Animate(...)

Animations.Animate(kind, action) 是 Reactor 的 SwiftUI 风格事务性动画原语。 把一次状态修改包进去,由此产生的、对带 key 列表的任何结构性变更 (插入、移动、移除)都会采用这个 kind —— 全程看不到任何逐元素修饰符。

// ListView<T> 按 T.Key 给行加键,因此模型要实现 IReactorKeyed。
// 正是这一点让协调器能区分插入与替换 ——
// 也因此事务性动画才有意义。
record Todo(string Id, string Title) : IReactorKeyed
{
    public string Key => Id;
}

class TransactionalAnimateDemo : Component
{
    public override Element Render()
    {
        var initialItems = UseMemo<IReadOnlyList<Todo>>(() =>
            [new Todo("seed-1", "First"), new Todo("seed-2", "Second")]);
        var (items, setItems) = UseState(initialItems);

        return VStack(12,
            SubHeading("Animations.Animate — structural changes"),

            // 把 setter 包起来,新插入的列表项就会以弹簧动画出现。
            // 无需任何逐元素修饰符。
            Button("Add", () =>
                Animations.Animate(AnimationKind.Spring, () =>
                    setItems([.. items, new Todo(Guid.NewGuid().ToString(), "New")])))
                .AutomationName("Add animated todo"),

            ListView<Todo>(items, (t, _) => TextBlock(t.Title).Padding(8))
                .Height(200)
        ).Padding(24);
    }
}

AnimationKind 就是那个声明式的旋钮 —— SpringEaseInEaseOutEaseInOutDefaultNone。这个 kind 通过一个 AsyncLocal 环境值向下传递:在 Animate 内部调用的 setter 会在排队渲染之前先对环境值 拍快照,协调器则在差异比对那一轮重新压入该快照,于是 ListViewGridViewLazyVStack(以及手工构建的 FlexColumn(items.Select(...).WithKey(...)) 子元素)都会为由此产生的插入/移动/移除播放动画。(见 spec 042 §6。)

模板化的 ListView<T>(items, viewBuilder) 重载要求 T : IReactorKeyed —— 模型要暴露一个稳定的 string Key, 协调器正是靠它把一次变更判定为插入而非替换。当模型类型无法实现该接口时, 请用 ListView<T>(items, keySelector, viewBuilder) 这个重载。

Animate 做什么

Animate 只作用于结构性变更。某个叶子 TextBlockForegroundAnimate(.Spring) 内部发生变化时,前景色不会动画 —— 那仍然是逐元素 隐式过渡修饰符(.OpacityTransition(...) / .ScaleTransition(...) / .TranslationTransition(...) / .RotationTransition(...))或 AnimationScope.WithAnimation(...) 的职责。 这两条通道被刻意设计为彼此独立,以便保持 SwiftUI 那套 "withAnimation 只动画布局形态类操作"的契约;把它们混在一起, 会让带着那个心智模型过来的用户感到意外。

一旦显式设置了逐元素动画修饰符,它依旧优先:在某行上声明 .Transition(Transition.Fade),该行的进入/退出就使用 Fade,与环境值无关。 环境值是事务性场景下的默认值,而不是万能锤子。

嵌套与显式抑制

嵌套的 Animate 调用像 using 块一样层层堆叠 —— 其内部作用域内的状态变更 以内层 kind 为准,之后恢复外层 kind:

Animations.Animate(AnimationKind.Spring, () =>
{
    // 插入:以 Spring 动画。
    setItems([.. items, x]);

    Animations.Animate(AnimationKind.None, () =>
    {
        // None 内部的插入:不动画,即使我们仍在外层 Spring 事务中。
        // 当子组件需要退出调用方的隐式动画意图时,这很有用。
        setOtherItems([.. others, y]);
    });
});

减少动效

Animate调用点尊重系统的减少动效偏好。读取 UseReducedMotion(), 当用户选择关闭动效时就跳过这个包装:

var reduceMotion = UseReducedMotion();
Action<Todo> addItem = x =>
{
    Action commit = () => setItems([.. items, x]);
    if (reduceMotion) commit();
    else Animations.Animate(AnimationKind.Spring, commit);
};

WithAnimation 作用域

AnimationScope.WithAnimation() 把一次状态变更包起来,让作用域期间被修改的 每一个合成器属性都以给定曲线动画。在按钮处理器或副作用里调用它:

class WithAnimationDemo : Component
{
    public override Element Render()
    {
        var (opacity, setOpacity) = UseState(1.0);

        return VStack(12,
            SubHeading("WithAnimation Scope"),
            Button(opacity > 0.5 ? "Fade Out" : "Fade In", () =>
            {
                Microsoft.UI.Reactor.Animation.AnimationScope.WithAnimation(
                    Microsoft.UI.Reactor.Animation.Curve.Ease(300, Microsoft.UI.Reactor.Animation.Easing.Decelerate), () =>
                    {
                        setOpacity(opacity > 0.5 ? 0.2 : 1.0);
                    });
            }).AutomationName(opacity > 0.5 ? "Fade out with animation" : "Fade in with animation"),
            TextBlock("Compositor-animated via WithAnimation scope")
                .FontSize(18).Bold()
                .Opacity(opacity)
        ).Padding(24);
    }
}

WithAnimation 作用域

WithAnimation 捕获曲线、触发状态变更,渲染期间发生变化的任何 .Opacity().Scale().Translation().Rotation() 值都会在 合成器线程上动画。托管渲染瞬间完成 —— 动画跑在 GPU 上。

注意: AnimationScope 把当前曲线存放在一个 [ThreadStatic] 字段里, 因此这个环境作用域只在打开它的那个调用栈上存活。经典的翻车方式是: WithAnimation(Curve.Spring(), async () => { await Task.Delay(100); setVisible(false); })await 之后的 setVisible 运行时已经没有环境作用域了 —— 延续是一个全新的 栈帧,[ThreadStatic] 是空的 —— 于是属性变更瞬间生效,没有任何动画。 要做顺序动画,正确的形态是 WithAnimationAsync(它使用 CompositionScopedBatch 并返回一个任务),而不是在 WithAnimationawait。 框架的渲染宿主通过内部的 PushScope/PopScope 调用,在它自己的异步边界上 重建作用域;用户代码没有这个钩子。

.Animate() 修饰符

.Animate(curve) 给元素附加一个持久的隐式动画。每当合成器属性发生变化 (不透明度、偏移、缩放、旋转),它都会以给定曲线动画 —— 无需作用域:

class AnimateDemo : Component
{
    public override Element Render()
    {
        var (active, setActive) = UseState(false);

        return VStack(12,
            SubHeading(".Animate() Modifier"),
            Button(active ? "Reset" : "Animate", () => setActive(!active))
                .AutomationName(active ? "Reset animate modifier" : "Run animate modifier"),
            Border(
                TextBlock("Spring-animated").FontSize(18).Bold()
            ).Padding(12).CornerRadius(8).Background(Theme.CardBackground)
             .Opacity(active ? 0.5 : 1.0)
             .Animate(Microsoft.UI.Reactor.Animation.Curve.Spring(0.65f))
        ).Padding(24);
    }
}

Animate 修饰符

Curve.Spring() 获得回弹手感,或 Curve.Ease(ms) 获得定时缓动。 你可以用 AnimateProperty 标志位限定哪些属性参与动画: .Animate(Curve.Spring(), AnimateProperty.Opacity | AnimateProperty.Scale)。 合成器的上限是 Opacity | Offset | Scale | Rotation | CenterPoint —— 超出这个标志集合的任何东西(WidthHeight、布局槽位、画笔) 都不在 .Animate() 的覆盖范围内。尺寸变化由布局驱动时用 .LayoutAnimation(),碰到不寻常的属性则通过 .Set(...) 回落到 WinUI 的 Storyboard

交互状态

.InteractionStates() 在合成器层应用悬停、按下和聚焦效果,协调次数为零。 视觉反馈完全跑在 GPU 上 —— Reactor 的渲染循环根本不参与:

class InteractionStatesDemo : Component
{
    public override Element Render()
    {
        return VStack(12,
            SubHeading("InteractionStates"),
            TextBlock("Hover and press — zero reconcile, compositor-driven."),
            HStack(12,
                Border(
                    TextBlock("Hover me").FontSize(16).Bold()
                        .HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center)
                ).Padding(16).CornerRadius(8).Size(150, 60).Background(Theme.SystemSuccess)
                 .InteractionStates(s => s
                    .PointerOver(opacity: 0.85f, scale: 1.05f)
                    .Pressed(scale: 0.95f, opacity: 0.7f)),
                Border(
                    TextBlock("Press me").FontSize(16).Bold()
                        .HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center)
                ).Padding(16).CornerRadius(8).Size(150, 60).Background(Theme.AccentSecondary)
                 .InteractionStates(s => s
                    .PointerOver(scale: 1.03f)
                    .Pressed(scale: 0.97f, opacity: 0.8f),
                    curve: Microsoft.UI.Reactor.Animation.Curve.Spring(0.5f))
            )
        ).Padding(24);
    }
}

交互状态

构建器支持 .PointerOver(...).Pressed(...).Focused(...)。 每一个都接受可选的 opacityscaletranslationrotationbackgroundforegroundborderBrush 参数。传 curve: 参数 即可在状态之间使用弹簧或缓动过渡。

进入/退出过渡

.Transition() 在元素进入或离开元素树时播放动画。过渡之间可以用 +(并行)和 |(进入/退出不对称)组合:

class TransitionDemo : Component
{
    public override Element Render()
    {
        var (visible, setVisible) = UseState(true);

        return VStack(12,
            SubHeading("Enter/Exit Transition"),
            Button(visible ? "Hide" : "Show", () => setVisible(!visible))
                .AutomationName(visible ? "Hide transition sample" : "Show transition sample"),
            visible
                ? Border(
                    TextBlock("Fade + Slide").FontSize(16).Bold()
                        .HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center)
                ).Padding(12).CornerRadius(8).Size(200, 60).Background(Theme.SystemCritical)
                 .Transition(Microsoft.UI.Reactor.Animation.Transition.Fade + Microsoft.UI.Reactor.Animation.Transition.Slide(Microsoft.UI.Reactor.Animation.Edge.Bottom))
                : (Element)TextBlock("(removed from tree)")
        ).Padding(24);
    }
}

进入/退出过渡

内置过渡:

过渡 效果
Transition.Fade 淡入/淡出
Transition.Slide(edge) 从某条边滑入(Left、Top、Right、Bottom)
Transition.Scale(from) 从某个起始倍数缩放
a + b 两者并行运行
enter \| exit 进入与退出使用不同的过渡

默认曲线是 300ms + Decelerate 缓动。传一个自定义 Curve 作为第二个 参数即可覆盖。

错峰

容器上的 .Stagger(delay) 给每个子元素的进入过渡与布局动画叠加递增的 延迟,形成级联效果:

class StaggerDemo : Component
{
    public override Element Render()
    {
        var initialItems = UseMemo(() => new[] { "One", "Two", "Three", "Four", "Five" });
        var (items, setItems) = UseState(initialItems);

        return VStack(12,
            SubHeading("Staggered Animation"),
            Button("Shuffle", () => setItems(items.OrderBy(_ => Random.Shared.Next()).ToArray()))
                .AutomationName("Shuffle staggered items"),
            VStack(4, items.Select(item =>
                Border(TextBlock(item)).Padding(horizontal: 8, vertical: 12).Background(Theme.CardBackground)
                    .CornerRadius(4).LayoutAnimation()
                    .WithKey(item)
            ).ToArray()).Stagger(TimeSpan.FromMilliseconds(40))
        ).Padding(24);
    }
}

错峰动画

每个子元素的动画比前一个延迟 delay 毫秒开始。配合每个子元素上的 .LayoutAnimation().WithKey(),就能得到平滑的重排级联。

关键帧

.Keyframes(name, trigger, configure)trigger 变化时运行一个多属性 关键帧动画。在 0.0 到 1.0 的进度点上定义关键帧:

class KeyframeDemo : Component
{
    public override Element Render()
    {
        var (count, setCount) = UseState(0);

        return VStack(12,
            SubHeading("Keyframe Animation"),
            Button("Pulse!", () => setCount(count + 1))
                .AutomationName("Run pulse keyframe animation"),
            Border(
                TextBlock("Pulse target").FontSize(16).Bold()
                    .HAlign(HorizontalAlignment.Center).VAlign(VerticalAlignment.Center)
            ).Padding(12).CornerRadius(8).Size(200, 60).Background(Theme.AccentSecondary)
             .Keyframes("pulse", count, kf => kf
                .Duration(600)
                .At(0.0f, scale: global::System.Numerics.Vector3.One)
                .At(0.4f, scale: new global::System.Numerics.Vector3(1.3f, 1.3f, 1f), easing: Microsoft.UI.Reactor.Animation.Easing.Decelerate)
                .At(0.7f, scale: new global::System.Numerics.Vector3(0.95f, 0.95f, 1f))
                .At(1.0f, scale: global::System.Numerics.Vector3.One, easing: Microsoft.UI.Reactor.Animation.Easing.Accelerate))
        ).Padding(24);
    }
}

关键帧动画

构建器支持 .Duration(ms)、用于无限重复的 .Loop(),以及为每个关键帧 指定属性的 .At(progress, opacity?, scale?, translation?, rotation?, easing?)。 关键帧跑在合成器上 —— 播放期间不牵涉托管代码。

编排

AnimationScope.WithAnimationAsync() 返回一个 Task,它在合成器动画 结束时完成。用 await 把多次调用串起来,即可构建顺序动画:

class ChoreographyDemo : Component
{
    public override Element Render()
    {
        var (phase, setPhase) = UseState(0);

        return VStack(12,
            SubHeading("Choreography (WithAnimationAsync)"),
            Button("Run Sequence", async () =>
            {
                await Microsoft.UI.Reactor.Animation.AnimationScope.WithAnimationAsync(
                    Microsoft.UI.Reactor.Animation.Curve.Ease(200), () => setPhase(1));
                await Microsoft.UI.Reactor.Animation.AnimationScope.WithAnimationAsync(
                    Microsoft.UI.Reactor.Animation.Curve.Spring(0.7f), () => setPhase(2));
            }).AutomationName("Run choreography sequence"),
            TextBlock($"Phase: {phase}").FontSize(18).Bold()
                .Opacity(phase == 0 ? 1.0 : phase == 1 ? 0.3 : 1.0)
        ).Padding(24);
    }
}

编排

每个 await 都会等 CompositionScopedBatch 完成后再开始下一步。 引导流程、多步骤揭示,或任何必须按序发生的动画,都用它。

模式

路由切换时的页面进入/退出

给路由映射的页面套上 .Transition(...),让每个新页面滑动或淡入视图。 把 Transition.Slide(Edge.Right) | Transition.Slide(Edge.Left) (不对称 —— 从右边进入,向左边退出)与宿主上的 NavigationTransition.None 搭配,这样合成器动画就不会与宿主 默认的 Slide 相互竞争。路由管线见 Navigation

骨架屏到内容的淡入

数据加载时渲染骨架元素;数据到达后换成真实内容。分别在内容元素和骨架元素上 应用 .Transition(Transition.Fade, Curve.Ease(150)),这个交叉淡入看起来才是 有意为之,而不是生硬地弹出。挂起状态的管线可配合 UseResource

重排列表时保持身份稳定

布局动画需要跨重排保持身份的 UseState 更新。请务必给每个列表 子元素设置 .WithKey($"item-{model.Id}") —— 协调器通过匹配键来跟踪哪个元素 移动到了哪里,而 .LayoutAnimation() 正是从匹配到的元素上读取 旧 → 新位置。没有键,协调器会把这次重排当成销毁 + 重建,动画会因为没有 "旧位置"可动画而退化成淡入淡出。

常见错误

.Animate() 去动画 Width 或 Height

// 别这样:
Border(content).Width(expanded ? 400 : 200).Animate(Curve.Spring())

.Animate() 只覆盖合成器属性:Opacity、Offset(Translation)、Scale、 Rotation、CenterPoint。WidthHeight 是布局属性,合成器看不到它们。 元素会从 200 直接跳到 400,没有动画。两种正确形态:改用缩放 (Scale(expanded ? 2 : 1).ScaleTransition() —— 元素仍按 200 渲染, 但视觉上是 400),或者如果尺寸变化是由兄弟元素驱动的布局过程引起的, 就用 .LayoutAnimation() 动画。

WithAnimation 内部 await

// 别这样:
AnimationScope.WithAnimation(Curve.Ease(300), async () =>
{
    setStage("loading");
    await api.SaveAsync();
    setStage("done");   // 没有动画 —— 作用域已经没了。
});

AnimationScope[ThreadStatic] 的;await 的延续运行在另一个栈帧上, 作用域为空。第二个 setStage 就会不带动画地执行。请改用 WithAnimationAsync(返回一个绑定到合成批次的任务)并给每个阶段一条曲线, 或者用两次独立的 WithAnimation 调用夹住 await。

每次渲染都重跑关键帧

// 别这样:
.Keyframes("pulse", DateTime.Now, kf => ...)

.Keyframes 在其 trigger 值变化时重新运行。传入一个每次渲染都变化的值 (DateTime.Now、一个新分配的列表、一个内联 lambda)会让动画在每次协调时 重启 —— 元素会因为关键帧不断重置而闪烁。请用一个只在你确实想重新触发时才 递增的状态计数器(var (count, updateCount) = UseReducer(0), 然后 updateCount(c => c + 1))。

小贴士

时长要短。 200--400ms 感觉是灵敏的。超过 500ms 就显得迟钝。 除非有具体理由,否则用默认时长。

悬停/按下效果用 .InteractionStates() 它完全跑在合成器上,协调次数为零 —— 远比用 UseState 跟踪指针状态再重新渲染便宜。

条件渲染用 .Transition() 当元素通过 When() 或三元表达式进入或离开 元素树时,.Transition() 无需手动跟踪挂载/卸载就能加上润色效果。

组合过渡要节制。 每个元素一到两个过渡很自然。三个以上相互竞争的动画 会显得混乱。

布局动画一定要设 .WithKey() 没有稳定的键,协调器就无法跟踪哪个元素 移动到了哪里,动画会退化成简单的淡入淡出。

交互反馈用 Curve.Spring() 弹簧曲线对由用户触发的动画来说手感自然。 定时的、非交互的过渡用 Curve.Ease()

下一步

  • Localization —— 上一篇:翻译字符串、格式化数字/日期,并支持 RTL 布局
  • Charting —— 下一篇:用折线图、柱状图、面积图和饼图做数据可视化
  • Navigation —— 把连接动画与页面过渡搭配使用
  • Collections —— 让列表项在进入、重排和离开时播放动画
  • Styling and Theming —— 把动画与主题感知的颜色结合使用
  • Async Resources —— 把合成器批处理的动画与数据加载生命周期搭配