widget 布局与尺寸
row/column/grid/table/canvas、family 预算、when/unless 与锁屏 accessory。
小组件布局以 WidgetKit family 为边界:先定目标尺寸与信息密度,再选容器;不要把 large 的内容硬塞进 small。
边界:本篇讲 row/column/grid/table/canvas、widget.context与防裁切。桌面点击与widget.state见 状态和交互;参数面板见 参数面板。
#本篇目标
| 项 | 说明 |
|---|---|
| 核心原则 | 按 family 设计,不是把同一套 UI 整体缩小 |
| 容器 | row() / column() / grid() / table() / canvas() / layer() |
| 尺寸 API | widget.context、widget.family_value()、w.when() / w.unless() |
| 主屏 | small / medium / large(158²、338×158、338×354 内容区) |
| 锁屏 | circular / rectangular / inline — 极简,节点数严格受限 |
| 收尾 | 可变文案加 .line_limit(1).min_scale(0.65+) |
#快速开始
row() 横排三列指标,column() 在每列里纵向叠标题与数值——适合仪表盘,比单列更省高度。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
import widget
steps = widget.param.number("步数", 8420, min=0, max=50000)
calories = widget.param.number("千卡", 420, min=0, max=5000)
sleep_h = widget.param.number("睡眠(h)", 7, min=0, max=12)
accent = widget.param.color("主色", "#10B981")
w = widget.Widget(background=("#ECFDF5", "#064E3B"), padding=14)
w.text("今日健康", size=16, weight="semibold").line_limit(1)
with w.row(spacing=12):
with w.column(spacing=2):
w.text("步数", size=11, color="secondary").line_limit(1)
w.value(steps).monospaced_digit().line_limit(1).min_scale(0.72)
with w.column(spacing=2):
w.text("千卡", size=11, color="secondary").line_limit(1)
w.value(calories).monospaced_digit().line_limit(1).min_scale(0.72)
with w.column(spacing=2):
w.text("睡眠", size=11, color="secondary").line_limit(1)
(
w.value(sleep_h, unit="h")
.monospaced_digit()
.line_limit(1)
.min_scale(0.72)
)
w.rect(color=accent, height=3, corner_radius=2)
w.render()
要点:
- 容器必须用
with w.row():/with w.column():上下文管理器,不要把子节点当位置参数传入。 spacing=控制子项间距;align=可选leading/center/trailing。- 预览右上角切换 family,确认三列在 small 仍可读。
#心智模型
选定 family(small / medium / large / 锁屏 accessory)
↓
读 widget.context → content_width × content_height(扣掉 padding 后的可画区域)
↓
决定信息层级:标题 → 主值 → 次要说明 → 图表/表格
↓
选容器:语义流式用 row/column/grid;精确格子用 table;点位绘制用 canvas
↓
防裁切:line_limit + min_scale;small 隐藏次要行
#内容区预算(点)
以下为默认 padding 下的内容区宽高,脚本里用 widget.context 或 w.context 读取(属性,不可调用;不要写 widget.context()):
| Family | 约 content 宽 × 高 | 设计建议 |
|---|---|---|
small | 158 × 158 | 标题 + 1 主值 + 1 视觉元素 |
medium | 338 × 158 | 默认主力版;可横排 2–3 列 |
large | 338 × 354 | 可加第二段:图表、表格、说明行 |
circular | 64 × 64 | SF Symbol + 极短数字/缩写 |
rectangular | 160 × 56 | 一行或图标+一行字 |
inline | 220 × 22 | 单行文字,无图表/表格 |
常量:widget.SMALL、widget.MEDIUM、widget.LARGE、widget.CIRCULAR、widget.RECTANGULAR、widget.INLINE。
#容器怎么选
| API | 底层 | 适合 |
|---|---|---|
row(spacing=, align=) | 横向栈 | 多指标横排、按钮组 |
column(spacing=, align=) | 纵向栈 | 表单式上下结构 |
layer(align=) | 叠放 | 背景块上叠文字/图标 |
grid(columns=, spacing=, equal=) | 等列网格 | 周历、四宫格摘要 |
table(rows, columns) | 单路径表格线 | Bingo、日历格、精确分割线 |
canvas(fill=, coordinate_space=) | 点位坐标 | 表盘、键盘、自定义图形 |
日常卡片优先 row / column;需要不重叠的细表格线用 table(),不要用多个 rect 拼边框(线宽会叠粗、交点发圆)。
#布局示例
#三尺寸信息密度(is_family 分支)
同一脚本为 small 只保留单号+状态,medium 加图标行,large 再补预计送达。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
import widget
tracking = widget.param.text("单号", "SF1234567890")
status = widget.param.text("状态", "运输中")
eta = widget.param.text("预计", "明天 18:00")
accent = widget.param.color("主色", "#F59E0B")
ctx = widget.context
pad = widget.family_value(14, small=10, large=16)
w = widget.Widget(background=("#FFFBEB", "#292524"), padding=pad)
if ctx.is_family("small"):
w.text("快递", size=14, weight="semibold", color=accent).line_limit(1)
w.text(tracking, size=12).line_limit(1).min_scale(0.6)
w.text(status, size=11, color="secondary").line_limit(1).min_scale(0.72)
else:
w.text("快递追踪", size=16, weight="semibold").line_limit(1)
w.text(tracking, size=12, color="secondary").line_limit(1).min_scale(0.65)
with w.row(spacing=8):
w.symbol("shippingbox.fill").color(accent).font_size(16)
w.text(status, size=14, weight="semibold").line_limit(1).min_scale(0.72)
if ctx.is_family("large"):
w.text(f"预计送达 {eta}", size=12, color="secondary").line_limit(1)
else:
w.text(f"预计 {eta}", size=12, color="secondary").line_limit(1).min_scale(0.72)
w.render()
ctx.is_family("small")等价于ctx.is_family(widget.SMALL)。- 长单号在 small 必须
.min_scale(0.6)或缩短显示字段。
#family_value 与 w.when("large")
只改字号/边距用 family_value;整段 UI 仅 large 出现时用 w.when。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
import widget
show = widget.param.text("节目", "心灵马杀鸡")
episode = widget.param.text("集数", "第 128 期")
progress = widget.param.slider("进度", 0.42, min=0, max=1, step=0.01)
accent = widget.param.color("主色", "#8B5CF6")
title_size = widget.family_value(16, small=14, large=18)
bar_h = widget.family_value(6, small=5, large=8)
pad = widget.family_value(14, small=10, large=16)
w = widget.Widget(background=("#FAF5FF", "#1E1B4B"), padding=pad)
w.text(show, size=title_size, weight="semibold").line_limit(1).min_scale(0.72)
w.text(episode, size=12, color="secondary").line_limit(1).min_scale(0.72)
w.progress(float(progress), total=1, color=accent, height=bar_h)
with w.when("large", layout="column"):
w.text("继续收听 →", size=12, color=accent).line_limit(1)
w.render()
w.when("large", layout="column"):仅 large 渲染块内子节点;layout还可为"row"/"layer"。w.unless("small"):除 small 外都显示,适合「medium/large 才显示的脚注」。
#grid 周历打卡
grid(columns=7) 按子节点顺序从左到右、从上到下填格;equal=True 等宽列。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
import widget
days = ["一", "二", "三", "四", "五", "六", "日"]
plan = widget.param.choice("打卡计划", ["一三五", "天天练", "周末"], default="一三五")
accent = widget.param.color("打卡色", "#EF4444")
active_sets = {
"一三五": {0, 2, 4},
"天天练": set(range(7)),
"周末": {5, 6},
}
active = active_sets[str(plan)]
w = widget.Widget(background=("#FFFFFF", "#1C1C1E"), padding=12)
w.text("本周训练", size=15, weight="semibold").line_limit(1)
with w.grid(columns=7, spacing=4, equal=True):
for i, day in enumerate(days):
mark = "●" if i in active else "○"
(
w.text(day + mark, size=10, color=accent if i in active else "secondary")
.line_limit(1)
.min_scale(0.55)
.align("center")
)
w.render()
#table 九宫格(Bingo)
习惯打卡的纵向列表见 状态和交互;需要可见格线时用 table() + table.cell(row, col)。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
import widget
items = ["喝水", "散步", "整理", "阅读", "计划", "备份", "写作", "放松", "复盘"]
done = widget.state.list("bingo_done", [])
accent = widget.param.color("完成色", "#4F9A48")
w = widget.Widget(background=("#FBFAF5", "#141414"), padding=10)
w.text("今日九宫格", size=17, weight="bold").line_limit(1).min_scale(0.72).align("center")
with w.table(rows=3, columns=3, line_color=accent, line_width="hairline", fill=True) as table:
for i, label in enumerate(items):
checked = done.contains(i)
with table.cell(i // 3, i % 3):
(
w.button(
("✓ " if checked else "") + label,
action=done.toggle_item(i),
style="plain",
color=accent if checked else "#242326",
size=12,
press={"scale": 0.94},
normal={"scale": 1},
)
.line_limit(1)
.min_scale(0.55)
.align("center")
)
w.render()
表格线建议:
line_width="hairline":最细像素线。line_cap="butt"、line_join="miter":端点不圆、交点直角。- 格内文字
.min_scale(0.55),避免 small 格子裁切。
#canvas 读取可画区域
需要按点坐标摆放(表盘刻度、自定义键盘)时,先读 content_width / content_height,再 .place()。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
import widget
w = widget.Widget(background="#FFFFFF", padding=12)
ctx = widget.context
cw = ctx.content_width
ch = ctx.content_height
with w.canvas(fill=True, coordinate_space="points") as c:
c.rect(color="#EEF2F7", width=cw, height=ch).place(cw / 2, ch / 2, unit="points")
(
c.text(f"{int(cw)} × {int(ch)} pt", size=14, weight="semibold")
.line_limit(1)
.min_scale(0.72)
.place(cw / 2, ch / 2, unit="points")
)
(
c.text(ctx.family, size=11, color="secondary")
.line_limit(1)
.place(cw / 2, ch / 2 + 16, unit="points")
)
w.render()
ctx.width/ctx.height含 padding 前预算;摆放内容用content_*。coordinate_space="points":.place(x, y, unit="points")以画布左上为原点。
#锁屏 accessory 精简版
锁屏圆形/矩形/行内节点上限低(见上表);按 family 分别写布局,不要复用 medium 整块 UI。
预期效果:小组件预览面板显示与脚本一致的布局与交互。
import widget
steps = widget.state.int("steps", 6234)
accent = widget.param.color("主色", "#34D399")
ctx = widget.context
w = widget.Widget(background=("#F0FDF4", "#14532D"), padding=widget.family_value(8, circular=0, rectangular=4, inline=0))
if ctx.is_family("circular"):
w.symbol("figure.walk").color(accent).font_size(14)
w.text(str(int(steps) // 1000) + "k", size=11, weight="bold").line_limit(1).min_scale(0.6)
elif ctx.is_family("rectangular"):
with w.row(spacing=6):
w.symbol("figure.walk").color(accent).font_size(14)
(
w.value(steps, unit="步")
.monospaced_digit()
.line_limit(1)
.min_scale(0.65)
)
elif ctx.is_family("inline"):
w.text(f"今日 {int(steps)} 步", size=12).line_limit(1).min_scale(0.72)
else:
w.text("请切换到锁屏 accessory 预览", size=13, color="secondary").line_limit(2).min_scale(0.7)
w.render()
预览里选 circular / rectangular / inline 查看效果;发布后在锁屏编辑界面单独添加(见 从脚本到桌面)。
#防裁切清单
| 手段 | 用法 |
|---|---|
| 单行 + 缩放 | .line_limit(1).min_scale(0.65)(small 可降到 0.55) |
| 等宽数字 | .monospaced_digit() 避免数字跳动挤版 |
| 按 family 藏行 | if not ctx.is_family("small"): w.text(...) |
| 按 family 改字号 | widget.family_value(16, small=14, large=18) |
| 按 family 改边距 | padding=widget.family_value(14, small=10, large=18) |
| large 填空白 | w.when("large") 增加图表、说明、第二表格 |
| 格内文字 | 表格/Bingo .min_scale(0.55) + .align("center") |
#API 参考
#widget.context
ctx = widget.context # 或 w.context(绑定 Widget padding)
ctx.family # "small" | "medium" | "large" | ...
ctx.width / ctx.height # family 预算(点)
ctx.content_width # 扣 padding 后可画宽
ctx.content_height # 扣 padding 后可画高
ctx.is_family("small", "medium")
ctx.value(default, small=..., large=...) # 同 family_value,读当前 family
#widget.family_value
size = widget.family_value(16, small=14, medium=16, large=20)
pad = widget.family_value(14, small=10, rectangular=6, circular=4, inline=0)
#容器上下文管理器
with w.row(spacing=10, align="center"):
w.symbol("star.fill").color("#F59E0B")
w.text("标题")
with w.column(spacing=4, align="leading"):
w.text("副标题", size=12, color="secondary")
with w.layer(align="center", background="#EEF2F7", corner_radius=8):
w.text("叠放")
with w.grid(columns=2, spacing=8, equal=True):
w.text("A")
w.text("B")
with w.when("large", layout="column"):
w.line_chart([1, 3, 2, 5])
完整签名见 API 参考:row、column、grid、table、canvas、when、unless、family_value、.place()、.align()。
#常见错误
| 错误写法 | 后果 | 修正 |
|---|---|---|
w.row(w.text(...), spacing=8) | 子节点不进容器 | with w.row(): 块内添加 |
| large 布局原样给 small | 文字裁切、按钮重叠 | is_family / family_value 分支 |
多个 rect 拼表格线 | 线粗、交点圆 | 用 w.table(...) |
| 锁屏用 medium 整块 UI | 构建警告或裁切 | circular/rectangular/inline 单独写 |
canvas 用 width 当边界 | 内容溢出 padding | 用 content_width / content_height |
忘记 line_limit | 多行撑破高度 | 可变文案 .line_limit(1).min_scale(...) |
grid 子节点过多 | 挤到下一行难控 | 控制列数与单元内容极简 |
widget.context() / w.context() | TypeError: not callable | 用 ctx = widget.context 或 w.context |
#按钮按压样式
press={"scale": 0.94} 写在 w.button(...) 参数里;.pressed(scale=0.94) 是链式修饰符,二者等价。交互示例见 状态和交互。
#失败路径
| 现象 | 处理 |
|---|---|
| small 文字被裁切 | 减行数;min_scale 降到 0.55–0.65;隐藏次要 text |
| medium 正常、large 空 | w.when("large") 加图表/说明;勿只放大字号 |
| 表格线发粗/交点圆 | line_width="hairline"、line_cap="butt"、line_join="miter" |
| canvas 元素跑出边界 | 改用 content_* 算坐标;fill=True 铺满可画区 |
| 锁屏 accessory 空白 | 预览切换到 circular/rectangular/inline;检查是否走了 else 占位分支 |
| row 里列挤成一团 | 增大 spacing;减少列数;small 改单列 column |
| 预览与桌面布局不一致 | 两端的 family 要一致;查 排错 |
#相关文档
| 文档 | 用途 |
|---|---|
| widget 概览 | 总览与入门 |
| 参数面板 | widget.param 与布局配色 |
| 状态和交互 | 表格格内 toggle_item、按钮 |
| 从脚本到桌面 | 锁屏 accessory 添加步骤 |
| 时间线和动画 | 数字过渡、flip 动画 |
| 资源与外观 | 背景、渐变、图片 |
| 排错 | 裁切与预览不一致 |
| API 参考 | 布局节点完整签名 |
相关 API:widget-api-reference.md