媒体 API
Image、AsyncImage、PhotoPicker、CameraPicker、VideoPlayer、WebView 和 MapView。
本页覆盖图片、相册、相机、文件导入、地图、网页、视频和图标标题。媒体视图仍然是普通 View,可以继续使用 .frame(...)、.padding(...)、.background(...)、.clipped() 等修饰符。
#什么时候用
| 目标 | 首选 API | 说明 |
|---|---|---|
| SF Symbol 或本地图片 | Image | 图标、资产目录图片、可缩放图片。 |
| 网络图片 | AsyncImage | 支持占位视图和失败视图。 |
| 图标 + 标题 | Label | 按钮、列表行、Tab、菜单项里的标准图标标题。 |
| 从相册选择 | PhotoPicker | 选择图片或视频,回调返回路径列表。 |
| 拍照或录像 | CameraPicker | 使用系统相机,回调返回路径字符串。 |
| 从文件导入 | FileImporter | 打开系统文件选择器,回调返回导入文件路径列表。 |
| 视频播放 | VideoPlayer | 内联或全屏视频播放,支持 AirPlay、PiP。 |
| 播放器控制 | PlayerController | 复用同一个原生播放器实例,控制播放、暂停、进度、倍速、音量和事件回调。 |
| 网页内容 | WebView | 加载 URL 或内联 HTML。 |
| 地图展示 | MapView | 显示 Apple Maps、中心点、缩放跨度和标记。 |
#最小正确示例
已复制
import appui
state = appui.State(status="Ready", picked_count=0, imported_count=0)
def image_loaded():
state.status = "Image loaded"
def image_failed():
state.status = "Image failed"
def receive_photos(paths):
state.picked_count = len(paths or [])
state.status = f"Picked {state.picked_count} item(s)"
def receive_files(paths):
state.imported_count = len(paths or [])
state.status = f"Imported {state.imported_count} file(s)"
def body():
return appui.NavigationStack(
appui.List([
appui.Section("Image", [
appui.AsyncImage(
url="https://www.w3.org/Icons/w3c_home",
placeholder=appui.ProgressView(label="Loading"),
error_view=appui.Label("Failed", system_image="wifi.slash"),
content_mode="fit",
on_success=image_loaded,
on_failure=image_failed,
)
.frame(height=90)
.background("secondarySystemBackground", corner_radius=8),
]),
appui.Section("Picker", [
appui.PhotoPicker(
selection_limit=2,
filter="images",
on_picked=receive_photos,
label=appui.Label("Choose Photos", system_image="photo.on.rectangle"),
),
appui.Text(state.status).font("footnote").foreground_color("secondaryLabel"),
]),
appui.Section("Files", [
appui.FileImporter(
allowed_types=["text", "pdf", "csv"],
allows_multiple=True,
on_picked=receive_files,
label=appui.Label("Import Files", system_image="doc.badge.plus"),
),
]),
]).navigation_title("Media")
)
appui.run(body, state=state)
#图片和标签
| API | 签名 | 分类 |
|---|---|---|
Image | Image(name: Optional[str] = None, system_name: Optional[str] = None, systemName: Optional[str] = None) | media |
Label | Label(title: str = '', system_image: Optional[str] = None, image: Optional[str] = None, systemImage: Optional[str] = None) | text |
AsyncImage | AsyncImage(url: str = '', placeholder: Optional[View] = None, error_view: Optional[View] = None, content_mode: str = 'fit', on_success: Optional[Callable] = None, on_failure: Optional[Callable] = None) | media |
#Image 方法
| 方法 | 说明 |
|---|---|
.resizable() | 允许图片按 frame 缩放。 |
.aspect_ratio(ratio=None, content_mode='fit', **kwargs) | 设置宽高比和填充模式;content_mode 为 "fit" 或 "fill"。 |
.symbol_rendering_mode(mode) | SF Symbol 渲染:"hierarchical"、"palette"、"multicolor"、"monochrome"。 |
.image_scale(scale) | SF Symbol 尺寸:"small"、"medium"、"large"。 |
已复制
import appui
def body():
symbol = (
appui.Image(system_name="heart.fill")
.symbol_rendering_mode("multicolor")
.image_scale("large")
.foreground_color("systemPink")
)
photo = (
appui.Image(name="example")
.resizable()
.aspect_ratio(content_mode="fit")
.frame(height=90)
.background("secondarySystemBackground", corner_radius=8)
)
return appui.NavigationStack(
appui.VStack([symbol, photo], spacing=16)
.padding()
.navigation_title("Images")
)
appui.run(body)
#相册、相机和文件导入
| API | 签名 | 分类 |
|---|---|---|
PhotoPicker | PhotoPicker(selection_limit: int = 1, filter: str = 'images', on_picked: Optional[Callable] = None, label: Optional[View] = None, selectionLimit: Optional[int] = None, onPicked: Optional[Callable] = None, **kwargs: Any) | media |
CameraPicker | CameraPicker(source: str = 'camera', media_type: str = 'photo', on_captured: Optional[Callable] = None, label: Optional[View] = None, mediaType: Optional[str] = None, onCaptured: Optional[Callable] = None, **kwargs: Any) | media |
FileImporter | FileImporter(allowed_types: Optional[Union[str, Sequence[str]]] = None, allows_multiple: bool = False, copy: bool = True, on_picked: Optional[Callable] = None, label: Optional[View] = None, allowedTypes: Optional[Union[str, Sequence[str]]] = None, allowsMultiple: Optional[bool] = None, onPicked: Optional[Callable] = None, **kwargs: Any) | media |
回调契约:PhotoPicker.on_picked(paths) 与 FileImporter.on_picked(paths) 接收路径字符串列表;CameraPicker.on_captured(path) 接收单个路径字符串或空值。用户可能拒绝权限、取消选择或设备不可用,回调中要处理空路径和空列表。
已复制
import appui
state = appui.State(last_path="")
def receive_capture(path):
state.last_path = path or "No file"
def body():
return appui.NavigationStack(
appui.Form([
appui.Section("Camera", [
appui.CameraPicker(
source="front",
media_type="photo",
on_captured=receive_capture,
label=appui.Label("Take Photo", system_image="camera"),
),
appui.Text(state.last_path or "No capture yet")
.font("footnote")
.foreground_color("secondaryLabel"),
])
]).navigation_title("Camera")
)
appui.run(body, state=state)
已复制
import appui
state = appui.State(files=[])
def receive_files(paths):
state.files = paths or []
def body():
rows = [
appui.Text(path).font("footnote").line_limit(1)
for path in state.files
]
return appui.NavigationStack(
appui.Form([
appui.Section("Import", [
appui.FileImporter(
allowed_types=["text", "pdf", "csv"],
allows_multiple=True,
on_picked=receive_files,
label=appui.Label("Import Files", system_image="folder"),
),
]),
appui.Section("Files", rows or [
appui.ContentUnavailableView(
"No file",
system_image="doc",
description="Import a document first",
)
]),
]).navigation_title("Files")
)
appui.run(body, state=state)
#视频、网页和地图
#VideoPlayer {#video-player}
#PlayerController {#player-controller}
#WebView {#webview}
#视频 API 选择规则
| 场景 | 推荐写法 | 说明 |
|---|---|---|
| 只需要在页面里显示并播放一个视频 | VideoPlayer(url=...) | 最简单,适合普通预览、详情页视频。 |
| Mini App 需要播放/暂停/seek/倍速/进度保存/PiP 状态 | PlayerController + VideoPlayer(player=player) | AppUI 视频类应用的主入口。 |
| 不写 AppUI 页面,只写脚本播放音视频 | import avplayer | 脚本级媒体能力;AppUI 新页面不要优先用它控制内嵌播放器。 |
| API | 签名 | 分类 |
|---|---|---|
VideoPlayer | VideoPlayer(url: str = '', autoplay: bool = False, loop: bool = False, show_controls: bool = True, presentation: str = 'inline', allows_fullscreen: bool = True, allows_pip: bool = True, allows_airplay: bool = True, video_gravity: str = 'resizeAspect', enters_fullscreen_when_playback_begins: bool = False, exits_fullscreen_when_playback_ends: bool = True, showControls: Optional[bool] = None, allowsFullscreen: Optional[bool] = None, allowsPiP: Optional[bool] = None, allowsPictureInPicture: Optional[bool] = None, allowsAirPlay: Optional[bool] = None, videoGravity: Optional[str] = None, entersFullscreenWhenPlaybackBegins: Optional[bool] = None, exitsFullscreenWhenPlaybackEnds: Optional[bool] = None, allows_picture_in_picture: Optional[bool] = None, player: Optional[PlayerController] = None, player_id: Optional[str] = None, pause_on_disappear: Optional[bool] = None) | media |
PlayerController | PlayerController(id: str = 'main', url: str = '', autoplay: bool = False, loop: bool = False, rate: float = 1.0, volume: float = 1.0, allows_pip: bool = True, allows_airplay: bool = True, video_gravity: str = 'resizeAspect', pause_on_disappear: bool = True) | 公开类型 |
WebView | WebView(url: Optional[str] = None, html: Optional[str] = None) | media |
MapView | MapView(latitude: float = 37.7749, longitude: float = -122.4194, span: float = 0.05, markers: Optional[Sequence[Dict[str, Any]]] = None, map_style: str = 'automatic', mapStyle: Optional[str] = None) | media |
ShareLink | ShareLink(item: str = '', subject: Optional[str] = None, message: Optional[str] = None) | control |
VideoPlayer(url=...) 适合只展示并播放一个视频。视频类 Mini App 需要恢复播放进度、切集、外部按钮控制、倍速、音量或 PiP 状态时,先创建 PlayerController,再传给 VideoPlayer(player=player)。AppUI 页面不要再额外 import avplayer 去控制同一块内嵌视频;PlayerController 已经是 AppUI 的播放器控制入口。
PlayerController 默认 pause_on_disappear=True,页面退出或视图消失时会暂停对应播放器,避免视频声音继续播放。确实需要离开页面后继续播放时,显式传 pause_on_disappear=False。
已复制
import appui
player = appui.PlayerController(
id="episode-player",
url="https://example.com/video.mp4",
autoplay=True,
allows_pip=True,
pause_on_disappear=True,
)
@player.on_progress(interval=5)
def save_progress(seconds):
print("progress", seconds)
def skip_forward():
player.seek(player.current_time + 30)
def body():
return appui.NavigationStack(
appui.VStack([
appui.VideoPlayer(player=player).frame(height=220),
appui.HStack([
appui.Button("Play", action=player.play),
appui.Button("Pause", action=player.pause),
appui.Button("Skip", action=skip_forward),
]),
], spacing=12)
.padding()
.navigation_title("Player")
)
appui.run(body)
已复制
import appui
def body():
markers = [
{"latitude": 35.68, "longitude": 139.76, "title": "Tokyo"},
{"latitude": 35.69, "longitude": 139.70, "title": "Shinjuku"},
]
return appui.NavigationStack(
appui.ScrollView(
appui.VStack([
appui.MapView(
latitude=35.68,
longitude=139.76,
span=0.12,
markers=markers,
map_style="standard",
).frame(height=220),
appui.WebView(html="<h1>AppUI</h1><p>Inline HTML content.</p>")
.frame(height=180),
], spacing=16)
.padding()
).navigation_title("Map & Web")
)
appui.run(body)
#加载状态和权限
| API | 空值或失败时的表现 | 建议 |
|---|---|---|
AsyncImage | 无占位时可能显示空白;失败时使用 error_view。 | 总是提供 placeholder 和 error_view。 |
PhotoPicker | 用户取消时可能返回空列表。 | 在 on_picked 中处理 [] 或 None。 |
CameraPicker | 用户拒绝权限、取消或设备不可用时可能没有路径。 | 在 on_captured 中处理空路径并显示说明。 |
FileImporter | 用户取消时不会产生有效路径;部分外部文件类型可能无法读取。 | 默认保持 copy=True,在 on_picked 中处理 [] 或 None。 |
VideoPlayer | 空 url 没有有效播放源。 | 文档示例可展示空控件,真实应用必须传可播放地址。 |
WebView | 未传 url / html 时没有明确内容。 | 显式传 URL 或 HTML。 |
MapView | 无标记也能显示中心点。 | span 不要过大,标记字典至少包含 latitude、longitude。 |
ShareLink | item 为空时分享面板没有有意义内容。 | 先在状态或函数里生成可读文本、URL 或文件路径。 |
#与相邻 API 的区别
| API | 不同点 |
|---|---|
Image vs Label | Image 只有图像;Label 是图标和标题组合,按钮和列表行更常用。 |
Image vs AsyncImage | Image 用本地资源或 SF Symbol;AsyncImage 从网络 URL 加载。 |
PhotoPicker vs CameraPicker | PhotoPicker 从已有媒体库选择;CameraPicker 创建新媒体。 |
PhotoPicker vs FileImporter | PhotoPicker 只面向相册媒体;FileImporter 从 Files App 或文档提供方导入普通文件。 |
WebView vs Link | WebView 在 AppUI 内嵌网页;Link 打开系统浏览器。 |
VideoPlayer vs WebView | 视频用 VideoPlayer,不要用 WebView 包一层视频网页来播放。 |
#常见错误
| 错误 | 正确做法 |
|---|---|
AsyncImage 不设固定高度,加载后布局跳动。 | 用 .frame(height=...) 固定媒体区域。 |
网络图片使用 "fill" 但忘记 .clipped()。 | 填充裁切时加 .clipped()。 |
| 相册或相机回调假设一定有路径。 | 对空列表、空字符串和权限拒绝做处理。 |
需要普通文档却用 PhotoPicker。 | 用 FileImporter(allowed_types=[...], on_picked=...)。 |
用 Image(...).on_tap(...) 模拟按钮。 | 可点击媒体动作使用 Button(content=appui.Image(...)) 或 Button(content=appui.Label(...))。 |
在用户文档里混用命令式 ui 写法。 | AppUI 文档只展示声明式 body() + appui.run(...)。 |