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() 在 VStack、HStack 和 Grid 元素上动画
背景颜色的变化:
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。它们只在面板元素
(StackPanel、Grid)上生效,因为 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 就是那个声明式的旋钮 —— Spring、EaseIn、EaseOut、
EaseInOut、Default 或 None。这个 kind 通过一个 AsyncLocal
环境值向下传递:在 Animate 内部调用的 setter 会在排队渲染之前先对环境值
拍快照,协调器则在差异比对那一轮重新压入该快照,于是 ListView、GridView、
LazyVStack(以及手工构建的 FlexColumn(items.Select(...).WithKey(...))
子元素)都会为由此产生的插入/移动/移除播放动画。(见 spec 042 §6。)
模板化的 ListView<T>(items, viewBuilder) 重载要求
T : IReactorKeyed —— 模型要暴露一个稳定的 string Key,
协调器正是靠它把一次变更判定为插入而非替换。当模型类型无法实现该接口时,
请用 ListView<T>(items, keySelector, viewBuilder) 这个重载。
Animate 不做什么¶
Animate 只作用于结构性变更。某个叶子 TextBlock 的 Foreground
在 Animate(.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 捕获曲线、触发状态变更,渲染期间发生变化的任何
.Opacity()、.Scale()、.Translation() 或 .Rotation() 值都会在
合成器线程上动画。托管渲染瞬间完成 —— 动画跑在 GPU 上。
注意:
AnimationScope把当前曲线存放在一个[ThreadStatic]字段里, 因此这个环境作用域只在打开它的那个调用栈上存活。经典的翻车方式是:WithAnimation(Curve.Spring(), async () => { await Task.Delay(100); setVisible(false); })。await之后的setVisible运行时已经没有环境作用域了 —— 延续是一个全新的 栈帧,[ThreadStatic]是空的 —— 于是属性变更瞬间生效,没有任何动画。 要做顺序动画,正确的形态是WithAnimationAsync(它使用CompositionScopedBatch并返回一个任务),而不是在WithAnimation里await。 框架的渲染宿主通过内部的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);
}
}

传 Curve.Spring() 获得回弹手感,或 Curve.Ease(ms) 获得定时缓动。
你可以用 AnimateProperty 标志位限定哪些属性参与动画:
.Animate(Curve.Spring(), AnimateProperty.Opacity | AnimateProperty.Scale)。
合成器的上限是 Opacity | Offset | Scale | Rotation | CenterPoint ——
超出这个标志集合的任何东西(Width、Height、布局槽位、画笔)
都不在 .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(...)。
每一个都接受可选的 opacity、scale、translation、rotation、
background、foreground 和 borderBrush 参数。传 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¶
.Animate() 只覆盖合成器属性:Opacity、Offset(Translation)、Scale、
Rotation、CenterPoint。Width 和 Height 是布局属性,合成器看不到它们。
元素会从 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 在其 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 —— 把合成器批处理的动画与数据加载生命周期搭配