原生列表与详情
NavigationStack、NavigationLink、搜索、刷新和滑动操作。
演示 List + searchable、NavigationLink 详情、swipe_actions 与 refreshable 的标准列表流。
#预期效果
运行后会出现原生列表详情页,搜索、分类、导航详情和行级操作都能产生可见反馈。
#适合场景
- 项目列表、课程目录、素材库、文件浏览等一列数据进入详情的页面。
- 需要搜索、筛选、下拉刷新、行滑动操作和详情页的标准列表流。
#页面结构
| 区域 | 结构 | 作用 |
|---|---|---|
| 顶层 | NavigationStack | 承载列表标题和详情页返回栈。 |
| 筛选区 | Section + Picker | 切换分类并展示当前状态。 |
| 内容区 | List + ForEach + NavigationLink | 稳定渲染动态行并进入详情。 |
| 行操作 | .swipe_actions(...) | 完成、删除等高频动作。 |
#完整示例
已复制
import appui
import haptics
state = appui.State(
query="",
filter="全部",
status="就绪",
items=[
{"id": "inbox", "title": "收件箱整理", "subtitle": "今天完成 12 条消息归档", "tag": "工作", "done": False},
{"id": "clip", "title": "剪贴板素材", "subtitle": "保存常用文案和链接", "tag": "工具", "done": True},
{
"id": "video",
"title": "视频片源检查",
"subtitle": "确认 HLS 和 MP4 是否可播放",
"tag": "媒体",
"done": False,
},
{"id": "health", "title": "血压周报", "subtitle": "统计最近 7 天平均值", "tag": "健康", "done": True},
],
)
def set_query(value):
state.query = value
def set_filter(value):
state.filter = value
def visible_items():
query = state.query.strip().lower()
rows = []
for item in state.items:
if state.filter != "全部" and item["tag"] != state.filter:
continue
text = f"{item['title']} {item['subtitle']} {item['tag']}".lower()
if query and query not in text:
continue
rows.append(item)
return rows
def toggle_done(item_id):
rows = []
for item in state.items:
copy = dict(item)
if copy["id"] == item_id:
copy["done"] = not copy["done"]
rows.append(copy)
state.items = rows
haptics.selection()
def delete_item(item_id):
state.items = [item for item in state.items if item["id"] != item_id]
state.status = "已删除"
haptics.notification("success")
def reload_items():
state.status = "已刷新"
haptics.selection()
def detail_view(item):
def toggle_current():
toggle_done(item["id"])
return appui.Form(
[
appui.Section(
[
appui.LabeledContent("标题", value=item["title"]),
appui.LabeledContent("分类", value=item["tag"]),
appui.LabeledContent("状态", value="完成" if item["done"] else "进行中"),
],
header="信息",
),
appui.Section(
[
appui.Text(item["subtitle"]).font("body"),
appui.Button("切换完成状态", action=toggle_current),
],
header="操作",
),
]
).navigation_title(item["title"])
def row_view(item):
def toggle_current():
toggle_done(item["id"])
def delete_current():
delete_item(item["id"])
status_icon = "checkmark.circle.fill" if item["done"] else "circle"
status_color = "systemGreen" if item["done"] else "secondaryLabel"
return appui.NavigationLink(
destination=detail_view(item),
label=appui.HStack(
[
appui.Image(system_name=status_icon).foreground_color(status_color),
appui.VStack(
[
appui.Text(item["title"]).font("headline"),
(
appui.Text(item["subtitle"])
.font("caption")
.foreground_color("secondaryLabel")
),
],
alignment="leading",
spacing=3,
),
appui.Spacer(),
appui.Text(item["tag"]).font("caption").foreground_color("systemBlue"),
],
spacing=10,
),
).swipe_actions(
actions=[
appui.Button("完成", action=toggle_current),
appui.Button("删除", role="destructive", action=delete_current),
]
)
def row_key(item):
return item["id"]
def body():
rows = visible_items()
content = (
appui.ContentUnavailableView(
"没有结果",
system_image="magnifyingglass",
description="换个关键词或分类再试",
)
if not rows
else appui.ForEach(rows, row_view, key=row_key)
)
return appui.NavigationStack(
appui.List(
[
appui.Section(
[
appui.Picker(
"分类",
selection=state.filter,
options=["全部", "工作", "工具", "媒体", "健康"],
on_change=set_filter,
),
appui.LabeledContent("状态", value=state.status),
],
header="筛选",
),
appui.Section("事项", [content]),
]
)
.searchable(text=state.query, on_change=set_query)
.refreshable(reload_items)
.navigation_title("事项")
)
appui.run(body, state=state, presentation="sheet")
#关键技巧
state.items只存纯数据(字典列表),不要存入Text/Button等视图对象。- 搜索用
List(...).searchable(...),不要用自绘TextField条顶替系统搜索栏。 - 详情进路由用
NavigationLink;行级操作放swipe_actions;下拉刷新用refreshable。
#失败路径
- 搜索和筛选没有结果时显示
ContentUnavailableView,不要让列表区域空白。 - 删除或刷新后把结果写回
state.status,用户能看见刚才发生了什么。
#可替换点
| 当前写法 | 可替换为 |
|---|---|
静态 state.items | storage.get_json(...) 或网络请求结果 |
haptics.selection() | 普通状态文本、toast 或 alert |
分类 Picker | 搜索栏、分段控件或多个筛选字段 |