widget 时间线和动画
timeline/entry、state 数字动画、flip 翻页与 countdown 倒计时。
Widget 动画由 WidgetKit 在数据更新时触发:timeline entry 切换、AppIntent 改写 widget.state,或系统按计划刷新。不是 App 里的 60fps 循环动画。
边界:本篇讲w.timeline、widget.entry、数字过渡与flip。布局防裁切见 布局与尺寸;按钮触发 state 见 状态和交互。
#本篇目标
| 项 | 说明 |
|---|---|
| 两类数据源 | widget.state(桌面点击)与 widget.entry(时间线条目字段) |
| 数字动画 | .content_transition("numericText") + .monospaced_digit() + .id(...) |
| 时间线 | w.timeline(entries=[...], update=, after=) 声明未来快照 |
| 翻页块 | w.flip(current, previous=...) 配合 entry 前后值 |
| 系统计时 | w.countdown / w.timer_text — WidgetKit 原生倒计时 |
| 限制 | 无持续循环;刷新时机由系统调度 |
#快速开始
桌面点「消费」后余额数字滚动过渡——这是 state + numericText,不依赖 timeline。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
已复制
import widget
balance = widget.state.int("balance", 1280)
accent = widget.param.color("主色", "#10B981")
num_size = widget.family_value(42, small=34, large=52)
w = widget.Widget(background=("#ECFDF5", "#064E3B"), padding=14)
w.text("零钱罐", size=14, color="secondary").line_limit(1)
(
w.value(balance.text("¥{}"))
.id("balance")
.font_size(num_size)
.monospaced_digit()
.content_transition("numericText")
.line_limit(1)
.min_scale(0.65)
)
(
w.button("消费 ¥10", action=balance.decrement(by=10), background=accent, color="#FFFFFF")
.line_limit(1)
.min_scale(0.72)
)
w.render()
要点:
balance.text("¥{}"):把 state 格式化成带货币符号的展示串,同时保留 state 绑定。.content_transition("numericText"):数值变化时系统数字滚动动画。.id("balance"):给变化节点稳定身份,动画更可靠。- 预览里点按钮即可看动画;桌面需发布后在小组件上点击。
#心智模型
已复制
┌─────────────────────────────────────┐
│ WidgetKit 刷新时机 │
└─────────────────────────────────────┘
系统调度 / timeline 到期 / AppIntent 改 state
↓
当前 entry 字段 或 state 新值 写入快照
↓
节点带 content_transition / flip / animation / transition
↓
系统播放一次过渡动画
#widget.state vs widget.entry
widget.state | widget.entry | |
|---|---|---|
| 谁写入 | 桌面 AppIntent(按钮、开关) | w.timeline entries 每条快照 |
| 典型用途 | 计数器、余额、开关 | 按计划变化的展示数、翻页前后值 |
| 绑定写法 | widget.state.int("key", 0) | widget.entry.int("price", 0.58) |
| 格式化 | count.text("{} 次") | price.text("{:.2f}") |
| 动画触发 | 点击后 state 变 | 预览/桌面切到下一 entry |
同一张卡片可以同时有 state(交互)和 entry(定时),但动画字段要各绑各的 key,不要混用。
#动画 API 怎么选
| API | 适合 |
|---|---|
.content_transition("numericText") | value() / 数字 state / entry 变化 |
.content_transition("opacity") | 文案淡入淡出 |
.animation("smooth", value_by="price") | entry 字段变化时额外平滑(常配合 numericText) |
.transition("push", edge="bottom") | 节点插入/替换方向 |
w.flip(current, previous=...) | 翻页钟、日历块、前后对比数字 |
w.countdown(..., target=) | 距某时刻剩余时间(系统驱动) |
#时间线示例
#分时电价(timeline + entry + 数字动画)
声明 3 条未来快照;price / prev_price 为 entry 字段。预览里时间线会按条目轮播(Studio 约每秒切一帧)。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
已复制
import widget
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
price = widget.entry.float("price", 0.62)
prev_price = widget.entry.float("prev_price", 0.68)
accent = widget.param.color("强调色", "#F59E0B")
w = widget.Widget(background=("#FFFBEB", "#292524"), padding=14)
w.timeline(
entries=[
{"date": now.isoformat(), "price": 0.68, "prev_price": 0.72},
{"date": (now + timedelta(hours=1)).isoformat(), "price": 0.62, "prev_price": 0.68},
{"date": (now + timedelta(hours=2)).isoformat(), "price": 0.55, "prev_price": 0.62},
],
update="end",
)
w.text("分时电价", size=14, color="secondary").line_limit(1)
(
w.value(price.text("{:.2f}"), unit="元/度")
.content_transition("numericText")
.animation("smooth", duration=0.35, value_by="price")
.monospaced_digit()
.line_limit(1)
.min_scale(0.72)
)
if not widget.context.is_family("small"):
(
w.text(prev_price.text("上一档 {:.2f}"), size=11, color="secondary")
.line_limit(1)
.min_scale(0.72)
)
w.render()
- 每条 entry 是
dict;必须含date(ISO 8601 字符串,建议 UTC)。 - entry 里其它 key(
price、prev_price)与widget.entry.*("price", ...)的 name 对应。 update="end":在最后一条 entry 之后请求刷新(映射 WidgetKit.atEnd)。
#翻页分钟(flip)
flip 需要当前值与 previous 两个 entry 字段;适合时钟、日历翻页。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
已复制
import widget
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
minute = widget.entry.int("minute", 41)
prev_minute = widget.entry.int("prev_minute", 40)
accent = widget.param.color("数字色", "#38BDF8")
flip_w = widget.family_value(110, small=88, large=130)
flip_h = widget.family_value(72, small=58, large=88)
flip_sz = widget.family_value(44, small=32, large=52)
w = widget.Widget(background=("#0B1220", "#020617"), padding=16)
w.timeline(
entries=[
{"date": now.isoformat(), "minute": 41, "prev_minute": 40},
{"date": (now + timedelta(minutes=1)).isoformat(), "minute": 42, "prev_minute": 41},
{"date": (now + timedelta(minutes=2)).isoformat(), "minute": 43, "prev_minute": 42},
],
update="end",
)
w.text("当前分钟", size=13, color="#94A3B8").line_limit(1).min_scale(0.72).line_limit(1)
(
w.flip(
minute.text("{:02d}"),
previous=prev_minute.text("{:02d}"),
width=flip_w,
height=flip_h,
size=flip_sz,
color="#F8FAFC",
background="#1E293B",
corner_radius=10,
direction="up",
)
.id("flip-minute")
)
w.render()
minute.text("{:02d}"):entry 整数格式化为两位(与 Python 占位符一致)。direction="up"/"down"控制翻页方向;尺寸用family_value防 small 溢出。
#阶段文案(entry + transition)
非数字也可用 entry 驱动;文案切换加 content_transition / transition。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
已复制
import widget
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
phase = widget.entry.str("phase", "候场")
accent = widget.param.color("主色", "#8B5CF6")
w = widget.Widget(background=("#FAF5FF", "#1E1B4B"), padding=14)
w.timeline(
entries=[
{"date": now.isoformat(), "phase": "候场"},
{"date": (now + timedelta(minutes=3)).isoformat(), "phase": "演讲进行中"},
{"date": (now + timedelta(minutes=20)).isoformat(), "phase": "问答环节"},
],
update="end",
)
w.text("活动状态", size=14, color="secondary").line_limit(1)
(
w.text(phase, size=18, weight="semibold", color=accent)
.id("phase-label")
.content_transition("opacity")
.transition("push", edge="bottom")
.line_limit(1)
.min_scale(0.72)
)
w.render()
#系统倒计时(无需手写 entries)
距目标时刻的剩余时间由 WidgetKit 自动走时;适合会议、车次、截止时间。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
已复制
import widget
from datetime import datetime, timedelta, timezone
eta = datetime.now(timezone.utc) + timedelta(hours=1, minutes=25)
accent = widget.param.color("主色", "#6366F1")
w = widget.Widget(background=("#EEF2FF", "#1E1B4B"), padding=14)
w.text("会议倒计时", size=14, weight="semibold").line_limit(1).min_scale(0.72)
(
w.countdown(
"距离会议",
target=eta.isoformat(),
subtitle="三楼 302 · 可提前 5 分钟入场",
accent=accent,
)
)
w.render()
target接受 ISO 8601 字符串或datetime。- 底层使用
timer_text;与w.timeline互补:一个管「走到某时刻」,一个管「预先排好的多条快照」。
#w.timeline 刷新策略
update | 行为 |
|---|---|
"after"(默认) | 在 after 指定时刻之后请求下次刷新;或用 interval(秒) |
"end" / "atEnd" | 在最后一条 entry 之后刷新 |
"never" | 不再自动刷新 |
"rapid" | 向系统请求更频繁刷新;不保证实时频率,仍受 WidgetKit 预算合并 |
已复制
from datetime import datetime, timedelta, timezone
now = datetime.now(timezone.utc)
w.timeline(
entries=[{"date": now.isoformat(), "value": 1}],
update="after",
after=(now + timedelta(minutes=30)).isoformat(),
)
# 或用间隔秒数(桌面映射为 after = now + interval)
w.timeline(entries=[...], update="after", interval=900)
注意:
- 没有 timeline 时,桌面主要靠系统刷新预算 + 用户点击改 state。
update="rapid"适合「希望尽量跟紧」的场景(如短周期电价、临近截止的倒计时),但仍是请求而非定时器;系统可能合并或延后刷新,构建诊断可能给出 warning。- 预览里 Studio 轮播 entry(约每秒一帧)不等于桌面刷新频率;桌面由 WidgetKit 调度。
.animation(..., duration=, value_by=)的duration控制过渡时长;value_by填 entry 字段名(如"price"),与content_transition("numericText")常一起用。
#推荐写法速查
#交互数字(state)
已复制
score = widget.state.int("score", 0)
(
w.value(score, unit="分")
.id("score")
.monospaced_digit()
.content_transition("numericText")
)
w.button("+", action=score.increment())
#时间线数字(entry)
已复制
amount = widget.entry.int("amount", 100)
w.timeline(entries=[
{"date": "2026-06-10T08:00:00Z", "amount": 100},
{"date": "2026-06-10T09:00:00Z", "amount": 128},
], update="end")
(
w.value(amount.text("{}"))
.content_transition("numericText")
.animation("smooth", value_by="amount")
)
#稳定身份
变化频繁的节点建议 .id("唯一串");entry 绑定节点也可自动生成 entry:price 类身份,显式 id 更利于排查。
#常见错误
| 错误写法 | 后果 | 修正 |
|---|---|---|
只有 value(count) 无 content_transition | 数字跳变无动画 | 加 .content_transition("numericText") |
用 widget.state 绑 timeline 字段 | entry 不随时间线变 | timeline 字段用 widget.entry.* |
entry 无 date | 顺序错乱或只用间隔推算 | 每条写 ISO date |
flip 只有当前值无 previous | 翻页效果弱 | 同时声明 previous=prev_*.text(...) |
| 期望 60fps 循环 | Widget 不支持 | 改用 timeline 分段或 countdown |
| 改 timeline 不重新发布 | 桌面仍旧快照 | 重新运行发布 |
#失败路径
| 现象 | 处理 |
|---|---|
| 点击按钮数字不动 | 确认 action 来自 widget.state;已加 content_transition |
| 预览 timeline 不轮播 | 检查 entries 非空、date 合法;重新运行预览 |
| 数字无动画、直接跳 | 加 .monospaced_digit()、.id(...)、content_transition |
flip 不翻页 | 确认 previous 绑 entry;timeline 至少 2 条 |
| 桌面时间不更新 | 发布最新构建;countdown 的 target 是否在未来 |
| 预览正常、桌面无动画 | 系统可能合并刷新;删小组件重装;查 排错 |
| 动画卡顿或缺失 | 减少同屏动画节点;small 降低 font_size / flip 尺寸 |
#相关文档
| 文档 | 用途 |
|---|---|
| widget 概览 | 总览 |
| 状态和交互 | widget.state、AppIntent |
| 布局与尺寸 | family_value、防裁切 |
| 从脚本到桌面 | 发布后在桌面验证 |
| 排错 | 预览/桌面不一致 |
| API 参考 | timeline、entry、flip、修饰符签名 |
相关 API:widget-api-reference.md