列表与表单
List、ForEach、Form、Section、搜索、刷新和滑动操作。
List 与 Form 是展示结构化数据的首选:List 适合可选中、滑动操作的行;Form 适合设置页分组。ForEach 把 Python 序列映射为多行视图;Section 提供分组标题与脚注。修饰符 .list_style、.swipe_actions、.refreshable、.searchable 直接链在列表(或承载搜索的外层容器)上;若要显式包裹整行,也可使用 SwipeActions(content=..., leading=..., trailing=...)。当前框架不提供 List / ForEach 原生 on_delete 契约,删除动作请放在行级滑动按钮中。
#预期效果
示例会展示原生 List 的搜索、刷新、空状态、行操作和长列表承载方式。
#概念速览
| 能力 | API |
|---|---|
| 列表 | List(content) |
| 数据驱动行 | ForEach(data, row_builder, key=...) |
| 表单 | Form(content)、Section("标题", content)、Section(content, header=...) |
| 样式 / 交互 | .list_style(style)、.swipe_actions、.refreshable(action)、.searchable(text, on_change) |
| 行外观 | .list_row_background(color)、.list_row_separator(visibility) |
#与 AppUI 的对应关系
List/Form都是 纵向 容器;Form默认呈现分组设置样式,但仍可用Section组织子控件。ForEach不是布局,它把 Python 可迭代对象 展开 为多个子视图,因此必须保证row_builder(item)每次返回一棵合法的View子树。.searchable是 修饰符:搜索框属于页面的一部分,过滤逻辑仍由你在body()里用State完成(见进阶示例)。State对嵌套list/dict有深度观察;为减少多余刷新,批量修改请用batch_update。
#list_style 常用取值
automatic、inset、inset_grouped、grouped、plain、sidebar。设置页多用 inset_grouped,侧边栏列表可用 sidebar。
#基础示例
静态 Form + Section,以及一个简单的 List。
import appui
state = appui.State(notify=True, note="")
def set_note(value):
state.note = value
def set_notify(value):
state.notify = value is True or value == "True" or value == "true"
def body():
return appui.NavigationStack(
appui.Form(
[
appui.Section(
"账户",
[
appui.TextField(
"备注",
text=state.note,
on_change=set_note,
).text_field_style("rounded_border"),
],
),
appui.Section(
"通知",
[
appui.Toggle(
label="接收提醒",
is_on=state.notify,
on_change=set_notify,
),
],
footer="关闭后仍保留本地数据。",
),
]
)
.navigation_title("表单示例")
)
appui.run(body, state=state)
#进阶示例
ForEach 动态行、.swipe_actions 删除、.searchable 过滤、.refreshable 下拉刷新、.list_style('inset_grouped'),以及 .list_row_background / .list_row_separator。
下面示例刻意把 过滤逻辑 放在 body() 顶部函数 filtered_items() 中,而不是藏在内联回调深处,便于单元测试与复用;refreshable 仅更新提示字符串,避免阻塞 UI 线程。
import appui
state = appui.State(
items=["买牛奶", "写 appui 教程", "跑步三公里"],
query="",
refreshing_hint="",
)
def delete_item(name):
remaining = [x for x in state.items if x != name]
state.items = remaining
def set_query(value):
state.query = value
def refresh_list():
state.refreshing_hint = "已触发刷新回调(可在此拉取网络数据)"
def item_key(item):
return item
def row_view(item):
def delete_current():
delete_item(item)
return (
appui.Text(item)
.list_row_background("secondarySystemBackground")
.list_row_separator("hidden")
.swipe_actions(
edge="trailing",
content=[
appui.Button(
"删除",
role="destructive",
action=delete_current,
)
],
)
)
def filtered_items():
q = state.query.strip().lower()
if not q:
return list(state.items)
return [x for x in state.items if q in x.lower()]
def body():
rows = filtered_items()
return appui.NavigationStack(
appui.VStack(
[
appui.Text("在搜索框输入以过滤").font("caption").foreground_color("secondaryLabel"),
appui.List(
[
appui.ForEach(
rows,
row_builder=row_view,
key=item_key,
)
]
)
.searchable(
text=state.query,
on_change=set_query,
)
.refreshable(action=refresh_list)
.list_style("inset_grouped"),
appui.Text(state.refreshing_hint or "下拉列表以触发 refreshable")
.font("footnote")
.foreground_color("systemOrange"),
],
spacing=8,
)
.padding()
.navigation_title("列表与搜索")
)
appui.run(body, state=state)
#常见误区
ForEach必须提供稳定key:字符串列表可用命名函数返回字符串本身;对象列表应使用唯一 id,否则删除 / 重排时行状态会错乱。.searchable修饰的是「承载列表的视图」:通常与List链在一起;搜索文本要存入State并在body()里用同一字段过滤数据。swipe_actions挂在「行视图」上:如示例中对Text(item)链式调用;挂在List整表上不会给每行单独滑动按钮。- 原地修改
state.items列表:state.items.remove(x)若列表是普通list可能不会触发深度观察;推荐state.items = new_list或batch_update(与State刷新规则一致)。 Section的参数顺序:推荐Section("标题", [views], footer="...");需要关键字时再用Section(content=[views], header="标题")。嵌套在Form中时务必传入 视图列表。refreshable的回调尽量短:下拉刷新结束后系统才会收起转圈;若在回调里做长时间阻塞,界面会看起来「卡住」。可在回调里只更新标志位,把重活放到线程(注意线程安全与State更新方式)。list_row_background颜色与深色模式:硬编码#F2F2F7在深色模式下可能对比不佳;可改用语义色如secondarySystemBackground。- 同一行不要同时依赖横向
on_drag与滑删:系统滑动按钮与自定义水平拖拽共用手势通道;建议拆成不同交互区域或移除该行的滑动按钮。
#练习题
- 把
List改为List+NavigationLink,每行进入只读详情页。 - 在
swipe_actions的edge='leading'侧增加「完成」按钮,并用.tint('green')区分颜色。 - 用
appui.LabeledContent在Form最后一节展示当前过滤结果条数。
#附录:仅构建 List 树(不调用 run)
import appui
data = ["a", "b"]
def text_row(value):
return appui.Text(value)
def text_key(value):
return value
tree = appui.List(
[
appui.ForEach(
data,
row_builder=text_row,
key=text_key,
)
]
)
print(tree)
#延伸阅读(仓库内)
完整页面可以组合 ForEach、swipe_actions、searchable 与 list_style。
#可选:LazyVStack 承载超长列表
当行数可能上千时,可将 ForEach 放进 ScrollView + LazyVStack,以减轻一次性构建整表的开销。入口写法见 appui 概览。
import appui
state = appui.State(rows=list(range(60)))
def lazy_row(value):
return appui.Text(f"第 {value} 行").padding(edges="horizontal")
def lazy_key(value):
return value
def body():
return appui.NavigationStack(
appui.ScrollView(
appui.LazyVStack(
[
appui.ForEach(
state.rows,
row_builder=lazy_row,
key=lazy_key,
)
],
spacing=6,
)
)
.navigation_title("懒加载列表示例")
)
appui.run(body, state=state)
#排错清单(自查)
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 搜索框无反应 | on_change 未写回 State | 确保 setattr(state, 'query', v) |
| 滑动无删除按钮 | swipe_actions 挂在 List 上 | 移到行视图 |
| 误以为支持原生删除 | 直接寻找 on_delete / .onDelete | 改用行级 .swipe_actions(...) 或 SwipeActions(...) |
| 同一行横向拖拽失效 | 该行同时配置了 on_drag 与 swipe_actions | 保留其一,或把拖拽手势移到子区域 |
| 下拉不触发 | refreshable 未链到可滚动父级 | 与 List / ScrollView 链式连接 |
| 过滤结果不刷新 | filtered 未依赖 state | 把过滤函数放在 body() 内读取最新 state |