示例:紧凑仪表盘
看板卡片、网格布局和真实交互。
适合做首页概览、运营面板、学习进度页。这个样板使用 ScrollView + LazyVGrid,卡片高度稳定,点击卡片后会更新选中状态。
#预期效果
运行后会出现紧凑看板,指标卡、筛选表单和明细导航会随搜索和状态切换更新。
#完整代码
已复制
import appui
state = appui.State(selected="Revenue")
metrics = [
{
"id": "revenue",
"name": "Revenue",
"value": "$48.2K",
"delta": "+12%",
"color": "systemGreen",
},
{"id": "users", "name": "Users", "value": "8,420", "delta": "+5%", "color": "systemBlue"},
{"id": "errors", "name": "Errors", "value": "14", "delta": "-3%", "color": "systemOrange"},
{"id": "uptime", "name": "Uptime", "value": "99.98%", "delta": "stable", "color": "systemTeal"},
]
def metric_key(item):
return item["id"]
def select_metric(name):
state.selected = name
def metric_card(item):
def open_metric():
select_metric(item["name"])
content = appui.VStack([
appui.HStack([
appui.Text(item["name"]).font("caption").foreground_color("secondaryLabel"),
appui.Spacer(),
appui.Text(item["delta"]).font("caption").foreground_color(item["color"]),
]),
appui.Text(item["value"]).font("title2").bold(),
], alignment="leading", spacing=8)
return (
appui.Button(action=open_metric, content=content)
.button_style("plain")
.frame(max_width=appui.infinity, min_height=104, alignment="leading")
.padding(14)
.background("secondarySystemBackground", corner_radius=8)
)
def body():
grid = appui.LazyVGrid(
columns=[appui.flexible(minimum=140), appui.flexible(minimum=140)],
spacing=12,
content=[appui.ForEach(metrics, row_builder=metric_card, key=metric_key)],
)
header = appui.VStack([
appui.Text("Operations").font("largeTitle").bold()
.frame(max_width=appui.infinity, alignment="leading"),
appui.Text(f"Selected: {state.selected}")
.foreground_color("secondaryLabel")
.frame(max_width=appui.infinity, alignment="leading"),
], alignment="leading", spacing=4)
return appui.NavigationStack(
appui.ScrollView(
appui.VStack([header, grid], alignment="leading", spacing=16).padding(16)
)
.navigation_title("Dashboard")
)
appui.run(body, state=state)
#筛选表单与明细导航
运营类仪表盘如果有搜索、筛选和明细页,优先用 List + Section + NavigationLink。空结果用 ContentUnavailableView,不要只留空白页面。
已复制
import appui
state = appui.State(query="", status="All")
rows = [
{"id": "revenue", "name": "Revenue", "status": "Good", "value": "$48.2K"},
{"id": "errors", "name": "Errors", "status": "Needs Review", "value": "14"},
{"id": "uptime", "name": "Uptime", "status": "Good", "value": "99.98%"},
]
def row_key(row):
return row["id"]
def set_query(value):
state.query = value
def set_status(value):
state.status = value
def filtered_rows():
query = state.query.strip().lower()
result = rows
if state.status != "All":
result = [row for row in result if row["status"] == state.status]
if query:
result = [row for row in result if query in row["name"].lower()]
return result
def detail_view(row):
return appui.Form([
appui.Section("Metric", [
appui.LabeledContent("Name", value=row["name"]),
appui.LabeledContent("Status", value=row["status"]),
appui.LabeledContent("Value", value=row["value"]),
])
]).navigation_title(row["name"])
def row_view(row):
return appui.NavigationLink(
destination=detail_view(row),
label=appui.LabeledContent(row["name"], value=row["value"]),
)
def body():
visible = filtered_rows()
content = (
appui.ContentUnavailableView(
"No Metrics",
system_image="chart.bar",
description="Change the search or filter.",
)
if not visible
else appui.ForEach(visible, row_builder=row_view, key=row_key)
)
return appui.NavigationStack(
appui.List([
appui.Section("Filter", [
appui.Picker(
"Status",
selection=state.status,
options=["All", "Good", "Needs Review"],
on_change=set_status,
)
.picker_style("segmented")
]),
appui.Section("Metrics", [content]),
])
.searchable(text=state.query, on_change=set_query)
.navigation_title("Dashboard")
)
appui.run(body, state=state)
#可复用点
- 首页看板可以用
ScrollView + LazyVGrid,但普通数据列表仍优先用List。 - 卡片用
Button包裹,点击后必须更新状态或进入详情。 - 用
appui.infinity,不要写字符串".infinity"。 - 卡片
min_height固定,避免不同内容导致网格跳动。