PythonIDE Docs
中文
简体中文

相册浏览器

LazyVGrid、AsyncImage、PhotoPicker、详情导航和全屏预览。

本页演示:LazyVGrid + AsyncImage 展示远程缩略图、PhotoPicker 将相册资源加入列表、NavigationStack + NavigationLink 进入详情,以及用 .sheet 做全屏感预览。

#预期效果

运行后会出现相册网格,支持添加图片、打开详情、全屏查看和处理空状态。

#状态字段说明

字段类型意图更新来源
urls字符串列表,元素为 httpsfile://初始内置远程图;PhotoPicker 回调追加本地资源。
sheet_open布尔用户打开/关闭全屏预览。
sheet_url字符串当前预览指向的 URL;与 AsyncImageurl 参数一致。

保持 sheet_url 仅在 sheet_open 为真时读取,可避免旧 URL 在模态关闭后仍参与布局的短暂闪烁(示例未强制清空,以便演示简单)。

#要点

  • PhotoPicker(selection_limit, filter, on_picked, label)filterimagesvideosallon_picked 收到本地路径字符串列表。
  • AsyncImage(url, placeholder, error_view, content_mode)content_modefitfill
  • NavigationLink 可用 label 传入任意 View,适合「缩略图即入口」。
  • 模态预览使用根视图上的 .sheet(is_presented=..., content=..., on_dismiss=...)

#完整示例

python
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")

#说明

  1. 本地路径与 AsyncImage:相册回调多为文件路径;示例通过 file:// 前缀拼成 URL 形式字符串,便于与远程 https 资源统一存入 state.urls
  2. 导航与模态NavigationLink 负责栈内详情;sheet 适合「沉浸式预览」或临时操作面板,两者可同时存在。
  3. 性能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 与占位策略

  • 占位视图placeholdererror_view 均为可选 View;常见组合是 ProgressView() + Image(system_name="exclamationmark.triangle")
  • content_modefill 适合缩略图铺满单元格(配合 clip_shape);详情页大图多用 fit 保留完整画面。
  • 失败重试on_failure 回调可用于打点或递增计数器;若需 UI 级「重试」按钮,可在 State 中增加整型字段(例如 reload_nonce),在按钮 action 中自增该字段,并让 AsyncImageurl 字符串拼接该值,以便在下次 body() 重建时强制重新请求(仍只用已有 API)。

#导航栈与模态的职责划分

  • 导航栈(NavigationLink:适合「进入详情后仍希望保留返回手势与标题栈上下文」的路径。
  • sheet:适合「临时预览、一次性操作」;关闭时务必在 on_dismiss 中复位 is_presented 关联储,避免下次无法再次弹出。

#延伸阅读