动画
原子动画事务、transition、matched geometry、phase 与 keyframe animator。
appui 提供两类动画能力:隐式动画(.animation(type, value) 在依赖变化时插值)与 显式动画(appui.animate(action, type) 或 appui.transaction(...) 包裹一次原子状态更新)。此外还有 过渡(.transition)、变换(.scale_effect、.rotation_effect、.rotation_3d_effect)、共享几何(.matched_geometry_effect)、内容过渡(.content_transition)、相位动画(.phase_animator)与 iOS 17+ 原生 关键帧动画(.keyframe_animator)。
复杂效果使用一等对象 Animation、Transition、Gesture 与 Namespace。这些不是针对某类 MiniApp 的硬编码组合控件,而是与原生动画、过渡、手势和命名空间对应的通用原语;旧字符串写法会自动转换,继续兼容。
从 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、interactiveSpring、interpolatingSpring、smooth、snappy、bouncy |
| 显式动画 | 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) |
| 精确控制每段时长的复杂动画 | .keyframe_animator(keyframes, effect=...) |
| 可组合动画曲线 | Animation.spring(...).delay(...).repeat_count(...) |
| 组合 / 非对称过渡 | Transition.move(...).combined(...)、Transition.asymmetric(...) |
| 稳定共享命名空间 | Namespace() |
| 页面级 Hero / Zoom | .matched_transition_source(...) + .navigation_transition(...) |
#隐式 vs 显式:怎么选
- 隐式:适合「依赖项变化 → 自动插值」的 UI,如计数、开关、列表插入;把
.animation(..., value=state.x)放在 依赖该字段的子树 上。 - 显式:适合一次事务内多处
State同步更新(例如先收起面板再改标题)。把更新逻辑放进命名函数,再用appui.animate(update_state, "spring")保证同一帧动画曲线一致。
#animation 的 type 取值
default、linear、easeIn、easeOut、easeInOut、spring、interactiveSpring、interpolatingSpring、smooth、snappy、bouncy。camelCase、snake_case 与连字符写法会归一化到同一曲线;smooth、snappy、bouncy 在 iOS 16 会安全降级。
value 支持可 JSON 序列化的数字、字符串、布尔值、数组和对象。传入布尔值或集合时会绑定该值本身,不再退化为“每次整树刷新都动画”。appui.animate(action, type=...) 的 type 会随更新事务传入原生渲染器,完整树与 PresentationCoordinator 快路径使用同一曲线解析规则。
Animation 可以精确设置 duration、spring 参数、delay、speed、重复次数和 autoreverse。Transition 可以组合、分别声明插入/移除效果,并绑定自己的 Animation。动画插值、手势进度和完成判断留在原生主线程;Python 只发起状态事务和接收离散完成/取消事件。
动画事务具有明确的中断语义:新的原生 UI 事务开始时,尚未结束的旧事务会结算为 animation_cancelled,其旧回调图租约同时释放;迟到的原生 completion 不会再把 旧事务误报为完成。phase_animator 和 keyframe_animator 最多接受 128 个有限值, 单段时长上限为 60 秒;系统开启“减少动态效果”时,连续相位/关键帧动画会停用, 而页面内容仍正常显示。
spring = (
appui.Animation.spring(response=0.42, damping_fraction=0.84)
.delay(0.05)
.respecting_reduce_motion()
)
transition = appui.Transition.asymmetric(
insertion=appui.Transition.move("bottom").combined(appui.Transition.opacity()),
removal=appui.Transition.scale(0.92).combined(appui.Transition.opacity()),
).animation(spring)
#基础示例
用 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,且通常配合显式布局;不同 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()。视觉上的连续逐帧动画应优先使用 .phase_animator、.keyframe_animator、手势进度或原生 transition,不要用 Python Timer 模拟 60/120 Hz 帧循环。Timer 只适合低频业务采样;波形等新数据可以分批写入 Canvas,但帧间插值仍应交给原生动画系统。
#与 ReactiveState / 实时属性通道
高频控件(如 Slider)在 ReactiveState 场景下可能走实时属性通道;此时 .animation(..., value=...) 仍绑定在普通 Text 上即可。避免同一字段既高频写入,又驱动整棵页面频繁重建。若动画曲线和预期不一致,先拆分「需要动画的只读展示」与「高频写控件」,再查看 状态 API。