Highlight
这场 session 用音乐 app 示例展示 tvOS 14 的 SwiftUI 按钮样式、context menus、焦点 API 和 Lazy Grid,让 Apple TV app 处理遥控器焦点、长按操作和横向 shelf 布局。
核心内容
把 iPhone 或 iPad 的 SwiftUI 思路搬到电视上,第一处差异是输入方式。Apple TV 用户用 Siri Remote 移动焦点、长按按钮、在远距离观看大屏界面。一个音乐 app 的专辑按钮,如果只做成普通按钮,就缺少焦点高亮和遥控器拖动时的方向移动效果。
这场 session 用一个音乐 app 串起三个问题:专辑卡片需要电视风格的按钮和长按菜单;Now Playing 页面需要知道哪首歌正在获得焦点;登录页需要在用户名输入框和登录按钮之间控制默认焦点。所有例子都围绕 tvOS 14 的 SwiftUI API 展开。
最后一个问题是布局。Apple TV app 常见的主页由多个横向滚动 shelf 组成。Session 用 ScrollView、LazyHGrid 和 CardButtonStyle 组合出这种布局,并说明 Lazy Grid 会在滚动需要时再加载项目。
详细内容
电视风格的按钮和长按菜单
(01:19)CardButtonStyle 会创建一个 platter。按钮获得焦点时,platter 会抬起并高亮;用户在 Siri Remote 上拖动时,按钮会带方向移动效果。
Button(albumLabel, action: playAlbum)
.buttonStyle(CardButtonStyle())
关键点:
Button(albumLabel, action: playAlbum)是专辑入口,用户选中后执行播放动作。.buttonStyle(CardButtonStyle())把普通 SwiftUI button 换成 tvOS 的卡片按钮样式。- 这个样式负责焦点高亮和遥控器拖动反馈,不需要在按钮外再手写动画。
(02:20)如果默认高亮和焦点效果不合适,可以自定义 ButtonStyle。Session 的代码只展示结构,具体背景样式由 app 自己补齐。
struct MyNewButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.background(configuration.isPressed ? … : …) // Custom styling
}
}
Button(albumLabel, action: playAlbum)
.buttonStyle(MyNewButtonStyle())
关键点:
MyNewButtonStyle遵守ButtonStyle协议。makeBody(configuration:)返回按钮在不同状态下的视图。configuration.isPressed来自按钮状态,可用于切换按下时的背景。.buttonStyle(MyNewButtonStyle())让同一个样式复用到任意 button。
(03:04)长按专辑按钮可以打开 context menu。菜单项仍然用普通 Button 表达。
AlbumView()
.contextMenu {
Button("Add to Favorites", action: addAlbumToFavorites)
Button("View Artist", action: viewArtistPage)
Button("Discover Similar Albums", action: viewSimilarAlbums)
}
关键点:
AlbumView()是菜单挂载的目标视图。.contextMenu声明长按后出现的快捷操作区域。- 每个
Button是一个菜单动作,分别连接收藏、查看艺人页和发现相似专辑。
用焦点状态改变内容密度
(05:19)tvOS 14 新增 isFocused environment value。它会在最近的可聚焦祖先获得焦点时返回 true。Session 用 Now Playing 页面说明:获得焦点的歌曲显示艺人和专辑名,未获得焦点的歌曲只显示艺人名。
struct SongView: View {
var body: some View {
Button(action: playSong) {
VStack {
Image(albumArt)
DetailsView(...)
}
}.buttonStyle(MyCustomButtonStyle())
}
}
struct DetailsView: View {
...
@Environment(\.isFocused) var isFocused: Bool
var body: some View {
VStack {
Text(songName)
Text(isFocused ? artistAndAlbum : artistName)
}
}
}
关键点:
SongView使用Button,button 本身已经可聚焦。DetailsView读取@Environment(\.isFocused),不需要自己成为可聚焦视图。Text(isFocused ? artistAndAlbum : artistName)根据焦点状态切换展示信息。- Session 明确提醒:button、list 或由 UIKit 管理焦点的视图,不适合再叠加
focusablemodifier。
控制默认焦点并在状态变化后重置
(08:01)tvOS 会按几何位置计算初始焦点,通常落在屏幕最上方或最靠前的可聚焦视图。登录页默认聚焦用户名输入框是合理的;如果用户名和密码已经填好,默认焦点应该落到登录按钮。
@Namespace private var namespace
@State private var areCredentialsFilled: Bool
var body: some View {
VStack {
TextField("Username", text: $username)
.prefersDefaultFocus(!areCredentialsFilled, in: namespace)
SecureField("Password", text: $password)
Button("Log In", action: logIn)
.prefersDefaultFocus(areCredentialsFilled, in: namespace)
}
.focusScope(namespace)
}
关键点:
@Namespace private var namespace创建一个用于识别焦点范围的唯一 ID。areCredentialsFilled决定用户名输入框和登录按钮谁更适合作为默认焦点。.prefersDefaultFocus(..., in: namespace)只表达偏好,不直接操作全局焦点树。.focusScope(namespace)把这些偏好限制在当前VStack。
(10:35)清空登录表单后,需要把焦点恢复到默认位置。resetFocus environment action 会按同一个 namespace 重置焦点。
@Namespace private var namespace
@State private var areCredentialsFilled: Bool
@Environment(\.resetFocus) var resetFocus
var body: some View {
VStack {
TextField("Username", text: $username)
.prefersDefaultFocus(!areCredentialsFilled, in: namespace)
SecureField("Password", text: $password)
Button("Log In", action: logIn)
.prefersDefaultFocus(areCredentialsFilled, in: namespace)
Button("Clear", action: {
username = ""; password = ""
areCredentialsFilled = false
resetFocus(in: namespace)
})
}
.focusScope(namespace)
}
关键点:
@Environment(\.resetFocus)取出系统提供的重置焦点动作。- Clear 按钮先清空
username和password。 areCredentialsFilled = false让用户名输入框重新声明默认焦点偏好。resetFocus(in: namespace)只在当前 focus scope 内执行重置。
用 Lazy Grid 组织电视首页 shelf
(12:08)Apple TV app 常见横向 shelf 布局可以用 Lazy Grid 实现。Lazy Grid 会按需初始化项目,适合在横向滚动区域里放大量封面。
struct ShelfView: View {
var body: some View {
ScrollView([.horizontal]) {
LazyHGrid(rows: [GridItem()]) {
ForEach(playlists, id: \.self) { playlist in
Button(action: goToPlaylist) {
Image(playlist.coverImage)
.resizable()
.frame(…)
}
.buttonStyle(CardButtonStyle())
}
}
}
}
}
关键点:
ScrollView([.horizontal])创建横向滚动区域。LazyHGrid(rows: [GridItem()])用一行 grid 组织横向 shelf。ForEach(playlists, id: \.self)把播放列表逐个映射成封面按钮。Image(playlist.coverImage).resizable().frame(…)表示封面可缩放,尺寸由 app 指定。.buttonStyle(CardButtonStyle())让每个封面按钮获得 tvOS 卡片焦点效果。
核心启发
- 做一个专辑 shelf 主页
做什么:把音乐、视频或课程内容按播放列表分成多条横向 shelf。
为什么值得做:Session 展示的 ScrollView([.horizontal]) 和 LazyHGrid 正是 Apple TV app 常见布局;Lazy Grid 会在滚动需要时加载项目。
怎么开始:先用 LazyHGrid(rows: [GridItem()]) 渲染一条 shelf,再把每个封面包进 Button,并加上 CardButtonStyle()。
- 给内容卡片加长按快捷操作
做什么:在专辑、影片或歌单卡片上提供收藏、查看作者页、发现相似内容等快捷动作。
为什么值得做:Session 明确用长按专辑按钮打开 context menu,菜单项可以直接用普通 Button。
怎么开始:在卡片视图后添加 .contextMenu,把每个动作写成 Button("Action", action: handler)。
- 让 Now Playing 列表按焦点展开详情
做什么:当前获得焦点的歌曲展示艺人和专辑名,其他歌曲只显示艺人名,减少电视界面上的信息噪声。
为什么值得做:isFocused 可以让子视图读取最近可聚焦祖先的焦点状态,不需要给每个详情视图单独加焦点包装。
怎么开始:把文字区拆成 DetailsView,在其中读取 @Environment(\.isFocused),再根据布尔值切换 Text 内容。
- 给登录页设置状态驱动的默认焦点
做什么:未填写凭证时默认聚焦用户名输入框,凭证已填好时默认聚焦登录按钮。
为什么值得做:tvOS 默认按几何位置选初始焦点;登录流程更适合由表单状态决定焦点入口。
怎么开始:创建 @Namespace,在用户名输入框和登录按钮上分别添加 .prefersDefaultFocus,再用 .focusScope(namespace) 限定范围。
- 给清空表单动作补焦点重置
做什么:用户点击 Clear 后,清空用户名和密码,并把焦点送回用户名输入框。
为什么值得做:Session 展示了 resetFocus(in:),它会把焦点按当前 namespace 的默认偏好重新计算。
怎么开始:读取 @Environment(\.resetFocus),在 Clear 按钮 action 里先更新表单状态,再调用 resetFocus(in: namespace)。
关联 Session
- What’s new in SwiftUI — 了解 SwiftUI 在 iPhone、iPad、Mac、Apple Watch 和 Apple TV 上的年度更新。
- Stacks, Grids, and Outlines in SwiftUI — 补充 Lazy Grid、list 和 outline 相关布局能力,和本 session 的 shelf 布局直接相邻。
- App essentials in SwiftUI — 解释 app、scene 和 view 如何组成完整 SwiftUI app,适合作为 tvOS SwiftUI app 的结构前置。
- Master Picture in Picture on tvOS — 同属 tvOS 体验主题,讲全屏内容和画中画之间的切换。
- Discover search suggestions for Apple TV — 同属 Apple TV app 体验主题,讲 tvOS 搜索界面和
UISearchController集成。
评论
GitHub Issues · utterances