AnimControls
ui.fx动画控制:yoyo、时间线、关键帧与弹簧。Animation control: yoyo, timelines, keyframes and springs.
BEHAVIOR CONTRACT
播放、倒放、重置与跳转都作用于同一动画状态机。Play, reverse, reset and seek act on one animation state machine.
RECORDED INTERACTION
Harness 播放 yoyo 与时间线。The harness plays the yoyo and the timeline.
pub fn buildAnimCtl(scope: *ui.Scope, cx: *ui.Cx) anyerror!*ui.Node {
const a = cx.allocator;
const c = try col(cx, 12);
try c.appendChild(a, try label(cx, "Animation controls — yoyo replay / timeline reverse / keyframes catch-up / degenerate spring"));
// yoyo tween:20 → 120 → 20(loops=2 + yoyo),完成后停在 from。
const yoyo_ctrl = try a.create(ui.fx.AnimationController);
yoyo_ctrl.* = ui.fx.AnimationController.initTween(.{ .from = 20, .to = 120, .duration = 0.7, .easing = .linear });
yoyo_ctrl.yoyo = true;
yoyo_ctrl.loops = 2;
try scope.registerResource(@ptrCast(yoyo_ctrl), destroyAnimController);
// timeline:单条 0→100 线性 2s。
const tl_ctrl = try a.create(ui.fx.AnimationController);
tl_ctrl.* = ui.fx.AnimationController.initTween(.{ .from = 0, .to = 100, .duration = 2.0, .easing = .linear });
try scope.registerResource(@ptrCast(tl_ctrl), destroyAnimController);
const tl = try a.create(ui.fx.Timeline);
tl.* = ui.fx.Timeline.init(a);
try scope.registerResource(@ptrCast(tl), destroyAnimTimeline);
try tl.add(tl_ctrl, ui.fx.Position.start);
// 退化 spring:负 stiffness(修复后 precompute 三参全钳,不产 NaN)。
const spring_ctrl = try a.create(ui.fx.AnimationController);
spring_ctrl.* = ui.fx.AnimationController.initSpring(.{ .from = 0, .to = 100, .stiffness = -50, .damping = 10, .mass = 1 });
try scope.registerResource(@ptrCast(spring_ctrl), destroyAnimController);
// 可视化目标(值断言走 label;箱体只是肉眼观察辅助)
const arena = try ui.box(cx, .{
.width = .{ .px = 560 },
.height = .{ .px = 60 },
.background = light.color.bg_secondary,
}, .{});
const yoyo_box = try ui.box(cx, .{ .width = .{ .px = 28 }, .height = .{ .px = 28 }, .background = light.color.accent }, .{});
yoyo_box.meta.ownership.meta.test_id = "story.animctl.yoyobox";
try arena.appendChild(a, yoyo_box);
const spring_box = try ui.box(cx, .{ .width = .{ .px = 28 }, .height = .{ .px = 28 }, .background = light.color.border }, .{});
spring_box.meta.ownership.meta.test_id = "story.animctl.springbox";
try arena.appendChild(a, spring_box);
try c.appendChild(a, arena);
const yoyo_label = try ui.text(cx, "yoyo: state=idle value=20.0 replay_first=-1.0", .{ .font_size = 13, .color = light.color.fg_primary });
yoyo_label.meta.ownership.meta.test_id = "story.animctl.yoyo";
const tl_label = try ui.text(cx, "tl: state=idle t=0.00 v=0.0", .{ .font_size = 13, .color = light.color.fg_primary });
tl_label.meta.ownership.meta.test_id = "story.animctl.tl";
const kf_label = try ui.text(cx, "kf: loops=0 value=0.0 completed=false", .{ .font_size = 13, .color = light.color.fg_primary });
kf_label.meta.ownership.meta.test_id = "story.animctl.kf";
const spring_label = try ui.text(cx, "spring: ticks=0 value=0.0", .{ .font_size = 13, .color = light.color.fg_primary });
spring_label.meta.ownership.meta.test_id = "story.animctl.spring";
try c.appendChild(a, yoyo_label);
try c.appendChild(a, tl_label);
try c.appendChild(a, kf_label);
try c.appendChild(a, spring_label);
const st = try cx.bindState(AnimCtlStory, .{
.cx = cx,
.yoyo_ctrl = yoyo_ctrl,
.tl = tl,
.tl_ctrl = tl_ctrl,
.spring_ctrl = spring_ctrl,
.kf = ui.fx.KeyframeAnimation.init(&KF_FRAMES, 400, 4),
.yoyo_box = yoyo_box,
.spring_box = spring_box,
.yoyo_label = yoyo_label,
.tl_label = tl_label,
.kf_label = kf_label,
.spring_label = spring_label,
});
const btn_row = try row(cx, 8);
const yoyo_btn = try W.Button(.{ .label = "Play yoyo", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.playYoyo) }).mount(scope, cx);
yoyo_btn.meta.ownership.meta.test_id = "story.animctl.yoyo_play";
try btn_row.appendChild(a, yoyo_btn);
const tl_play_btn = try W.Button(.{ .label = "Play timeline", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.playTimeline) }).mount(scope, cx);
tl_play_btn.meta.ownership.meta.test_id = "story.animctl.tl_play";
try btn_row.appendChild(a, tl_play_btn);
const tl_rev_btn = try W.Button(.{ .label = "Reverse timeline", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.reverseTimeline) }).mount(scope, cx);
tl_rev_btn.meta.ownership.meta.test_id = "story.animctl.tl_reverse";
try btn_row.appendChild(a, tl_rev_btn);
const kf_reset_btn = try W.Button(.{ .label = "KF reset", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.kfReset) }).mount(scope, cx);
kf_reset_btn.meta.ownership.meta.test_id = "story.animctl.kf_reset";
try btn_row.appendChild(a, kf_reset_btn);
const kf_step_btn = try W.Button(.{ .label = "KF +1s", .size = .sm, .on_click = cx.on(AnimCtlStory, st, AnimCtlStory.kfAdvance) }).mount(scope, cx);
kf_step_btn.meta.ownership.meta.test_id = "story.animctl.kf_step";
try btn_row.appendChild(a, kf_step_btn);
try c.appendChild(a, btn_row);
c.meta.per_frame.hooks.slots.anim_state = @ptrCast(st);
c.meta.per_frame.hooks.before_render.main = AnimCtlStory.tickHook;
// spring 自动起播(mount 即驱动,e2e 只读回值)
spring_ctrl.play();
cx.needs_redraw = true;
return c;
}这是 Storybook 中该 story 的原样代码(zenit 5f9add5+wip 2026-09-30)。其中 W 即 ui.widgets,col / row / label 是 Storybook 内的布局小工具。The story's verbatim source from the Storybook (zenit 5f9add5+wip 2026-09-30). W is ui.widgets; col / row / label are small Storybook layout helpers.