动画
animate、animation、transition、matched geometry 和 phase animator。
appui 提供两类动画能力:隐式动画(.animation(type, value) 在依赖变化时插值)与 显式动画(appui.animate(action, type) 包裹一次状态更新)。此外还有 过渡(.transition)、变换(.scale_effect、.rotation_effect、.rotation_3d_effect)、共享几何(.matched_geometry_effect)、内容过渡(.content_transition)与 相位动画(.phase_animator)。
从 AppUI Presentation Engine 起,show_*、*_presented 等呈现状态字段变化会自动触发 spring 动画;sheet / alert / cover 的进出通常不必再手写 appui.animate()。
第四档 PresentationCoordinator 会在注册表有效时跳过 body() 与整树 JSON,直接更新原生 presentation binding;可用 appui.presentation_present / appui.presentation_dismiss_all 显式控制。完整规范见 Presentation Engine Spec。
#预期效果
示例会展示隐式动画、显式动画、转场和共享几何效果如何响应状态变化。
#概念速览
| 能力 | API |
|---|---|
| 隐式动画 | .animation(type='spring', value=...),type 可为 default、linear、easeIn、easeOut、easeInOut、spring、interpolatingSpring |
| 显式动画 | appui.animate(action, type) |
| 出现 / 消失 | .transition(type):opacity、slide、scale、move、push、identity |
| 2D / 3D 变换 | .scale_effect、.rotation_effect、.rotation_3d_effect |
| 共享元素 | .matched_geometry_effect(ns_id, namespace, is_source) |
| 文本或数字切换 | .content_transition(type) |
| 循环呼吸效果 | .phase_animator(phases) |
#隐式 vs 显式:怎么选
- 隐式:适合「依赖项变化 → 自动插值」的 UI,如计数、开关、列表插入;把
.animation(..., value=state.x)放在 依赖该字段的子树 上。 - 显式:适合一次事务内多处
State同步更新(例如先收起面板再改标题)。把更新逻辑放进命名函数,再用appui.animate(update_state, "spring")保证同一帧动画曲线一致。
#animation 的 type 取值
default、linear、easeIn、easeOut、easeInOut、spring、interpolatingSpring。未列出的字符串请避免使用,以免系统忽略。
#基础示例
用 appui.animate 更新计数,并对数字附加 .animation('spring', value=...)。
import appui
state = appui.State(score=0)
def decrease_value():
state.score -= 1
def increase_value():
state.score += 1
def decrease_score():
appui.animate(decrease_value, "easeOut")
def increase_score():
appui.animate(increase_value, "spring")
def body():
return appui.NavigationStack(
appui.VStack(
[
appui.Text(f"得分: {state.score}")
.font("largeTitle")
.bold()
.animation("spring", value=state.score),
appui.HStack(
[
appui.Button(
"−",
action=decrease_score,
).button_style("bordered"),
appui.Button(
"+",
action=increase_score,
).button_style("bordered_prominent"),
],
spacing=24,
),
],
spacing=24,
)
.padding()
.navigation_title("显式动画")
)
appui.run(body, state=state)
#进阶示例
transition + scale_effect + rotation_effect + rotation_3d_effect + matched_geometry_effect + content_transition + phase_animator。
import appui
state = appui.State(show_panel=False, label="轻点切换")
def toggle_panel():
state.batch_update(
show_panel=not state.show_panel,
label="面板已打开" if not state.show_panel else "面板已关闭",
)
def body():
panel = (
appui.RoundedRectangle(corner_radius=18)
.fill("systemTeal")
.frame(width=120, height=120)
.scale_effect(0.92)
.rotation_effect(6)
.matched_geometry_effect(ns_id="card", namespace="demo", is_source=True)
.transition("scale")
)
placeholder = (
appui.Circle()
.fill("systemGray4")
.frame(width=120, height=120)
.matched_geometry_effect(ns_id="card", namespace="demo", is_source=False)
.transition("opacity")
)
return appui.NavigationStack(
appui.ScrollView(
[
appui.VStack(
[
appui.Text(state.label)
.font("title2")
.content_transition("interpolate"),
appui.Button(
action=toggle_panel,
content=appui.Text("切换面板").padding(),
).button_style("bordered_prominent"),
panel if state.show_panel else placeholder,
appui.Text("3D 倾斜")
.font("footnote")
.foreground_color("secondaryLabel"),
appui.Rectangle()
.fill("systemOrange")
.frame(width=100, height=44)
.rotation_3d_effect(18, x=0, y=1, z=0),
appui.Text("相位动画")
.font("footnote")
.foreground_color("secondaryLabel"),
appui.Capsule()
.fill("systemPink")
.frame(width=160, height=36)
.phase_animator([0, 0.4, 1.0, 0.4, 0]),
],
spacing=20,
)
.padding()
]
)
.navigation_title("动画组合")
)
appui.run(body, state=state)
#常见误区
value与隐式动画:.animation(..., value=state.x)仅在value引用发生变化时触发动画;若传入常量则不会按预期刷新。appui.animate的第一个参数:必须是 无参可调用对象(推荐命名函数里批量改State),不要写成appui.animate(state.count + 1)。matched_geometry_effect:成对视图需 相同的namespace与ns_id,且通常配合显式布局;在复杂导航栈里要注意is_source切换时机。phase_animator:依赖 iOS 17+ 能力,在旧系统上可能降级或无动画。transition与条件渲染:过渡只在视图插入/移除时生效;若仅修改子视图文字,应优先.content_transition。interpolatingSpring与spring:二者曲线不同;若出现振荡或回弹过大,优先改回default或easeInOut验证是否由曲线本身引起。rotation_3d_effect与透视:极端角度可能导致子视图不可读;建议限制在 ±45° 内做 UI 提示级动效。
#练习题
- 用
ZStack叠放两个Text,通过state切换id(...),并分别设置.transition('slide')与.transition('move')。 - 给按钮增加
.sensory_feedback(style='success', trigger=str(state.score)),观察与animate联动的手感。 - 对照 入口函数 API,说明为何在回调里应优先使用
batch_update。
#延伸阅读(仓库内)
- 修饰符 API:查看
animation、transition、phase_animator等签名。
#附录:transition 与 opacity 最小片段
import appui
flag = True
def toggle():
global flag
flag = not flag
root = appui.Group(
[
appui.Text("A").opacity(1).transition("opacity"),
appui.Text("B").transition("push"),
]
)
assert root is not None
#调试建议
若动画「时有时无」,先在浏览器式调试思路下 二分法 排查:去掉 matched_geometry_effect 与 phase_animator 等高级修饰符,仅保留 .animation(..., value=...),确认基础路径正常后再逐项加回。
#DrawingContext 与动画的关系
本章专注视图修饰符与 animate();若需要在画布上做连续帧动画,常见做法是:用 State 保存相位或采样数组,在 Timer 或后台线程里更新 State,由 Canvas(..., commands=...) 或 DrawingContext 重建命令列表。此类模式属于「数据驱动重绘」,而不是 transition 插值。
#与 ReactiveState / 实时属性通道
高频控件(如 Slider)在 ReactiveState 场景下可能走实时属性通道;此时 .animation(..., value=...) 仍绑定在普通 Text 上即可。避免同一字段既高频写入,又驱动整棵页面频繁重建。若动画曲线和预期不一致,先拆分「需要动画的只读展示」与「高频写控件」,再查看 状态 API。