PythonIDE Docs
中文
简体中文

动画

原子动画事务、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)。

复杂效果使用一等对象 AnimationTransitionGestureNamespace。这些不是针对某类 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 可为 defaultlineareaseIneaseOuteaseInOutspringinteractiveSpringinterpolatingSpringsmoothsnappybouncy
显式动画appui.animate(action, type)
出现 / 消失.transition(type)opacityslidescalemovepushidentity
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") 保证同一帧动画曲线一致。

#animationtype 取值

defaultlineareaseIneaseOuteaseInOutspringinteractiveSpringinterpolatingSpringsmoothsnappybouncy。camelCase、snake_case 与连字符写法会归一化到同一曲线;smoothsnappybouncy 在 iOS 16 会安全降级。

value 支持可 JSON 序列化的数字、字符串、布尔值、数组和对象。传入布尔值或集合时会绑定该值本身,不再退化为“每次整树刷新都动画”。appui.animate(action, type=...)type 会随更新事务传入原生渲染器,完整树与 PresentationCoordinator 快路径使用同一曲线解析规则。

Animation 可以精确设置 duration、spring 参数、delay、speed、重复次数和 autoreverse。Transition 可以组合、分别声明插入/移除效果,并绑定自己的 Animation。动画插值、手势进度和完成判断留在原生主线程;Python 只发起状态事务和接收离散完成/取消事件。

动画事务具有明确的中断语义:新的原生 UI 事务开始时,尚未结束的旧事务会结算为 animation_cancelled,其旧回调图租约同时释放;迟到的原生 completion 不会再把 旧事务误报为完成。phase_animatorkeyframe_animator 最多接受 128 个有限值, 单段时长上限为 60 秒;系统开启“减少动态效果”时,连续相位/关键帧动画会停用, 而页面内容仍正常显示。

python
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=...)

python
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

python
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)

#常见误区

  1. value 与隐式动画.animation(..., value=state.x) 仅在 value 引用发生变化时触发动画;若传入常量则不会按预期刷新。
  2. appui.animate 的第一个参数:必须是 无参可调用对象(推荐命名函数里批量改 State),不要写成 appui.animate(state.count + 1)
  3. matched_geometry_effect:成对视图需 相同的 namespacens_id,且通常配合显式布局;不同 namespace 即使使用相同 ns_id 也会隔离。在复杂导航栈里要注意 is_source 切换时机。
  4. phase_animator:依赖 iOS 17+ 能力,在旧系统上可能降级或无动画。
  5. transition 与条件渲染:过渡只在视图插入/移除时生效;若仅修改子视图文字,应优先 .content_transition
  6. interpolatingSpringspring:二者曲线不同;若出现振荡或回弹过大,优先改回 defaulteaseInOut 验证是否由曲线本身引起。
  7. rotation_3d_effect 与透视:极端角度可能导致子视图不可读;建议限制在 ±45° 内做 UI 提示级动效。

#练习题

  1. ZStack 叠放两个 Text,通过 state 切换 id(...),并分别设置 .transition('slide').transition('move')
  2. 给按钮增加 .sensory_feedback(style='success', trigger=str(state.score)),观察与 animate 联动的手感。
  3. 对照 入口函数 API,说明为何在回调里应优先使用 batch_update

#延伸阅读(仓库内)

  • 修饰符 API:查看 animationtransitionphase_animator 等签名。

#附录:transitionopacity 最小片段

python
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_effectphase_animator 等高级修饰符,仅保留 .animation(..., value=...),确认基础路径正常后再逐项加回。


#DrawingContext 与动画的关系

本章专注视图修饰符与 animate()。视觉上的连续逐帧动画应优先使用 .phase_animator.keyframe_animator、手势进度或原生 transition,不要用 Python Timer 模拟 60/120 Hz 帧循环。Timer 只适合低频业务采样;波形等新数据可以分批写入 Canvas,但帧间插值仍应交给原生动画系统。


#ReactiveState / 实时属性通道

高频控件(如 Slider)在 ReactiveState 场景下可能走实时属性通道;此时 .animation(..., value=...) 仍绑定在普通 Text 上即可。避免同一字段既高频写入,又驱动整棵页面频繁重建。若动画曲线和预期不一致,先拆分「需要动画的只读展示」与「高频写控件」,再查看 状态 API