相册浏览器
LazyVGrid、AsyncImage、PhotoPicker、详情导航和全屏预览。
本页演示:LazyVGrid + AsyncImage 展示远程缩略图、PhotoPicker 将相册资源加入列表、NavigationStack + NavigationLink 进入详情,以及用 .sheet 做全屏感预览。
#预期效果
运行后会出现相册网格,支持添加图片、打开详情、全屏查看和处理空状态。
#状态字段说明
| 字段 | 类型意图 | 更新来源 |
|---|---|---|
urls | 字符串列表,元素为 https 或 file:// | 初始内置远程图;PhotoPicker 回调追加本地资源。 |
sheet_open | 布尔 | 用户打开/关闭全屏预览。 |
sheet_url | 字符串 | 当前预览指向的 URL;与 AsyncImage 的 url 参数一致。 |
保持 sheet_url 仅在 sheet_open 为真时读取,可避免旧 URL 在模态关闭后仍参与布局的短暂闪烁(示例未强制清空,以便演示简单)。
#要点
PhotoPicker(selection_limit, filter, on_picked, label):filter取images、videos或all;on_picked收到本地路径字符串列表。AsyncImage(url, placeholder, error_view, content_mode):content_mode为fit或fill。NavigationLink可用label传入任意View,适合「缩略图即入口」。- 模态预览使用根视图上的
.sheet(is_presented=..., content=..., on_dismiss=...)。
#完整示例
已复制
import appui
state = appui.State(
urls=[
"https://picsum.photos/id/64/600/600",
"https://picsum.photos/id/65/600/600",
"https://picsum.photos/id/66/600/600",
],
sheet_open=False,
sheet_url="",
)
def _normalize_url(path_or_url):
s = str(path_or_url)
if s.startswith(("http://", "https://", "file://")):
return s
return "file://" + s
def on_picked(paths):
next_urls = list(state.urls)
for p in paths or []:
u = _normalize_url(p)
if u not in next_urls:
next_urls.append(u)
state.urls = next_urls
def open_sheet(url):
state.batch_update(sheet_open=True, sheet_url=url)
def close_sheet():
state.batch_update(sheet_open=False, sheet_url="")
def thumb(url):
return appui.AsyncImage(
url=url,
placeholder=appui.ProgressView(),
error_view=appui.Image(system_name="photo"),
content_mode="fill",
).frame(height=120).clip_shape("rounded_rect").corner_radius(10)
def detail(url):
def preview_current():
open_sheet(url)
return appui.ScrollView(
appui.VStack(
[
appui.AsyncImage(
url=url,
placeholder=appui.ProgressView(),
error_view=appui.Image(system_name="exclamationmark.triangle"),
content_mode="fit",
).frame(max_width=appui.infinity),
appui.Button(
"全屏预览",
action=preview_current,
).button_style("bordered_prominent"),
],
spacing=12,
).padding(16)
).navigation_title("详情")
def sheet_root():
return appui.ZStack(
[
appui.Color("systemBackground"),
appui.VStack(
[
appui.HStack(
[
appui.Button("关闭", action=close_sheet).button_style("bordered"),
appui.Spacer(),
]
).padding(12),
appui.AsyncImage(
url=state.sheet_url,
placeholder=appui.ProgressView(),
content_mode="fit",
)
.frame(max_width=appui.infinity, max_height=appui.infinity)
.padding(12),
appui.Spacer(min_length=0),
]
),
]
)
def body():
col = appui.adaptive(minimum=104, maximum=160)
grid = appui.LazyVGrid(
columns=[col],
spacing=10,
content=[
appui.NavigationLink(
destination=detail(u),
label=thumb(u),
)
for u in state.urls
],
)
root = appui.NavigationStack(
appui.ScrollView(
appui.VStack(
[
appui.HStack(
[
appui.PhotoPicker(
selection_limit=6,
filter="images",
on_picked=on_picked,
label=appui.Label("从相册添加", system_image="photo.fill"),
),
appui.Spacer(),
]
).padding(horizontal=16),
grid.padding(horizontal=12),
],
spacing=12,
)
).navigation_title("相册浏览器")
)
return root.sheet(
is_presented=state.sheet_open,
on_dismiss=close_sheet,
content=sheet_root(),
)
appui.run(body, state=state, presentation="sheet")
#说明
- 本地路径与 AsyncImage:相册回调多为文件路径;示例通过
file://前缀拼成 URL 形式字符串,便于与远程https资源统一存入state.urls。 - 导航与模态:
NavigationLink负责栈内详情;sheet适合「沉浸式预览」或临时操作面板,两者可同时存在。 - 性能:
LazyVGrid仅构建可见区域的子视图,适合大量缩略图;仍建议控制selection_limit与列表长度。
#权限与数据来源
- 相册读取:
PhotoPicker依赖系统照片权限;首次使用若未授权,回调可能为空列表。示例在on_picked中对paths做空值容错(for p in paths or [])。 - 远程图片:
AsyncImage使用网络 URL 时需允许应用访问对应域;演示域名picsum.photos仅作占位,可替换为你自己的 CDN。 file://路径:不同运行环境返回的路径前缀可能不同;_normalize_url仅做最小归一化。若遇到加载失败,请检查当前环境是否允许读取该路径。
#交互变体
- 只用模态、不用导航栈:可将
NavigationStack替换为ScrollView+on_tap打开sheet,适合全屏单页工具。 - 详情页再加操作菜单:在
detail(url)内为工具栏添加Menu(title, content=[Button(...)]),将删除、分享等操作收纳到菜单中。
#AsyncImage 与占位策略
- 占位视图:
placeholder与error_view均为可选View;常见组合是ProgressView()+Image(system_name="exclamationmark.triangle")。 content_mode:fill适合缩略图铺满单元格(配合clip_shape);详情页大图多用fit保留完整画面。- 失败重试:
on_failure回调可用于打点或递增计数器;若需 UI 级「重试」按钮,可在State中增加整型字段(例如reload_nonce),在按钮action中自增该字段,并让AsyncImage的url字符串拼接该值,以便在下次body()重建时强制重新请求(仍只用已有 API)。
#导航栈与模态的职责划分
- 导航栈(
NavigationLink):适合「进入详情后仍希望保留返回手势与标题栈上下文」的路径。 sheet:适合「临时预览、一次性操作」;关闭时务必在on_dismiss中复位is_presented关联储,避免下次无法再次弹出。