布局系统
用原生容器搭列表、表单、网格和可滚动页面。
appui 的布局优先用组合:Stack 负责方向,ScrollView 负责滚动,Grid 负责网格,frame/padding/background 负责局部尺寸和外观。列表和设置页优先使用系统容器,复杂视觉再用自定义布局。
#预期效果
示例会展示 Stack、ScrollView、Grid、Form 和 GeometryReader 在不同页面结构中的可见布局结果。
#容器速查
| 容器 | 适合场景 | 常用参数 |
|---|---|---|
VStack | 垂直排列 | spacing, alignment |
HStack | 水平排列 | spacing, alignment |
ZStack | 层叠覆盖 | alignment |
ScrollView | 长内容和自定义滚动页 | axes, shows_indicators |
LazyVStack / LazyHStack | 长列表自定义布局 | spacing, alignment |
LazyVGrid / LazyHGrid | 卡片网格 | columns / rows, spacing |
Grid / GridRow | 表格式二维布局 | 行列组合 |
List | 原生动态列表 | Section, ForEach |
Form | 设置页和编辑页 | Section |
GeometryReader | 需要父容器尺寸 | on_change 或 geometry 回调 |
#Stack
Stack 解决 80% 的局部布局。用 VStack 组织纵向内容,用 HStack + Spacer 组织行。
已复制
import appui
state = appui.State(selected="Revenue")
def select_revenue():
state.selected = "Revenue"
def select_orders():
state.selected = "Orders"
def card(title, value, action):
content = appui.VStack([
appui.Text(title).font("caption").foreground_color("secondaryLabel"),
appui.Text(value).font("title2").bold(),
], alignment="leading", spacing=6)
return (
appui.Button(action=action, content=content)
.button_style("plain")
.padding(14)
.frame(max_width=appui.infinity, alignment="leading")
.background("secondarySystemBackground", corner_radius=12)
)
def body():
return appui.NavigationStack(
appui.VStack([
appui.Text("Dashboard").font("largeTitle").bold(),
appui.HStack([
card("Revenue", "$12.4k", select_revenue),
card("Orders", "284", select_orders),
], spacing=12),
appui.ZStack([
appui.Rectangle().fill("systemBlue").frame(height=120).corner_radius(16),
appui.Text(f"Selected: {state.selected}").foreground_color("white").bold(),
]),
], alignment="leading", spacing=16)
.padding()
.navigation_title("Stack")
)
appui.run(body, state=state)
只有 Stack 难以表达时,再使用 Grid 或 GeometryReader。
#ScrollView 与长内容
长列表优先用 List;需要完全自定义外观时再用 ScrollView + LazyVStack。
已复制
import appui
state = appui.State(selected="None")
def select_row(index):
state.selected = f"Item {index}"
def row(index):
def select_current():
select_row(index)
return appui.Button(
action=select_current,
content=appui.HStack([
appui.Text(str(index))
.frame(width=36, height=36)
.background("systemBlue", corner_radius=18)
.foreground_color("white"),
appui.VStack([
appui.Text(f"Item {index}").bold(),
appui.Text("Subtitle").font("caption").foreground_color("secondaryLabel"),
], alignment="leading"),
appui.Spacer(),
], spacing=12).padding(vertical=6),
).button_style("plain")
def body():
return appui.NavigationStack(
appui.ScrollView(
appui.LazyVStack([row(i) for i in range(1, 31)], spacing=8).padding(),
axes="vertical",
)
.safe_area_inset(
edge="bottom",
content=appui.Text(f"Selected: {state.selected}")
.padding()
.frame(max_width=appui.infinity, alignment="leading")
.background(material="regularMaterial"),
)
.navigation_title("ScrollView")
)
appui.run(body, state=state)
底部操作条优先用 safe_area_inset,不要靠固定 offset 硬顶。
#Grid
两三列卡片用 LazyVGrid 最直接;需要严格行列对齐时用 Grid/GridRow。
已复制
import appui
state = appui.State(selected="None")
def select_tile(index):
state.selected = f"Card {index}"
def tile(index):
def select_current():
select_tile(index)
content = appui.VStack([
appui.Image(system_name="square.grid.2x2").font("title2"),
appui.Text(f"Card {index}").font("caption"),
], spacing=8)
return (
appui.Button(action=select_current, content=content)
.button_style("plain")
.frame(max_width=appui.infinity, min_height=92)
.background("secondarySystemBackground", corner_radius=12)
)
def body():
columns = [appui.flexible(), appui.flexible()]
return appui.NavigationStack(
appui.ScrollView(
appui.VStack([
appui.LabeledContent("Selected", value=state.selected),
appui.LazyVGrid(
columns=columns,
spacing=12,
content=[tile(i) for i in range(1, 9)],
),
], spacing=16).padding()
).navigation_title("Grid")
)
appui.run(body, state=state)
#List 和 Form
普通数据列表使用 List + Section + ForEach,设置和编辑页使用 Form + Section。这两个容器负责原生滚动、分组、键盘避让和辅助功能。
已复制
import appui
state = appui.State(query="", notifications=True)
def set_query(value):
state.query = value
def set_notifications(value):
state.notifications = value
def body():
return appui.NavigationStack(
appui.Form([
appui.Section("Search", [
appui.TextField("Query", text=state.query, on_change=set_query),
]),
appui.Section("Preferences", [
appui.Toggle(
"Notifications",
is_on=state.notifications,
on_change=set_notifications,
),
], footer=f"Query: {state.query or '-'}"),
]).navigation_title("Form")
)
appui.run(body, state=state)
不要把每个设置项包成自绘卡片,也不要把 List 放进 ScrollView 或 Section 里。
#frame、padding、background
常见顺序:
已复制
(
view
.padding()
.frame(max_width=appui.infinity, alignment="leading")
.background("secondarySystemBackground")
.corner_radius(12)
)
| 需求 | 写法 |
|---|---|
| 占满宽度 | .frame(max_width=appui.infinity) |
| 固定高度 | .frame(height=120) |
| 左对齐 | .frame(max_width=appui.infinity, alignment="leading") |
| 卡片内边距 | .padding() 放在 .background() 前 |
| 外边距 | .padding() 放在 .background() 后 |
#GeometryReader
只有在需要父容器尺寸时使用 GeometryReader 或 .on_geometry(...)。不要把整页都包进 GeometryReader,它会改变布局提案,容易让子视图占满空间。
已复制
def remember_size(info):
print(info["width"], info["height"])
appui.GeometryReader(
content=appui.Text("Measure me"),
on_change=remember_size,
)
#常见问题
| 问题 | 检查 |
|---|---|
| 背景只包住文字 | .padding() 是否放在 .background() 前。 |
| 宽度没有撑开 | 是否设置了 .frame(max_width=appui.infinity)。 |
| 长内容滑不动 | 是否外层使用 ScrollView 或 List。 |
| 底部按钮挡住内容 | 使用 safe_area_inset(edge="bottom")。 |
| 网格宽度不均 | 检查 appui.flexible() 列配置和 spacing。 |
| 列表不像原生 | 是否用 ScrollView + VStack 模拟了动态列表。 |