WWDC Quick Look 💓 By SwiftGGTeam
Build SwiftUI apps for tvOS

Build SwiftUI apps for tvOS

观看原视频

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 用 ScrollViewLazyHGridCardButtonStyle 组合出这种布局,并说明 Lazy Grid 会在滚动需要时再加载项目。


详细内容

电视风格的按钮和长按菜单

01:19CardButtonStyle 会创建一个 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 管理焦点的视图,不适合再叠加 focusable modifier。

控制默认焦点并在状态变化后重置

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 按钮先清空 usernamepassword
  • 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 卡片焦点效果。

核心启发

  1. 做一个专辑 shelf 主页

做什么:把音乐、视频或课程内容按播放列表分成多条横向 shelf。

为什么值得做:Session 展示的 ScrollView([.horizontal])LazyHGrid 正是 Apple TV app 常见布局;Lazy Grid 会在滚动需要时加载项目。

怎么开始:先用 LazyHGrid(rows: [GridItem()]) 渲染一条 shelf,再把每个封面包进 Button,并加上 CardButtonStyle()

  1. 给内容卡片加长按快捷操作

做什么:在专辑、影片或歌单卡片上提供收藏、查看作者页、发现相似内容等快捷动作。

为什么值得做:Session 明确用长按专辑按钮打开 context menu,菜单项可以直接用普通 Button

怎么开始:在卡片视图后添加 .contextMenu,把每个动作写成 Button("Action", action: handler)

  1. 让 Now Playing 列表按焦点展开详情

做什么:当前获得焦点的歌曲展示艺人和专辑名,其他歌曲只显示艺人名,减少电视界面上的信息噪声。

为什么值得做isFocused 可以让子视图读取最近可聚焦祖先的焦点状态,不需要给每个详情视图单独加焦点包装。

怎么开始:把文字区拆成 DetailsView,在其中读取 @Environment(\.isFocused),再根据布尔值切换 Text 内容。

  1. 给登录页设置状态驱动的默认焦点

做什么:未填写凭证时默认聚焦用户名输入框,凭证已填好时默认聚焦登录按钮。

为什么值得做:tvOS 默认按几何位置选初始焦点;登录流程更适合由表单状态决定焦点入口。

怎么开始:创建 @Namespace,在用户名输入框和登录按钮上分别添加 .prefersDefaultFocus,再用 .focusScope(namespace) 限定范围。

  1. 给清空表单动作补焦点重置

做什么:用户点击 Clear 后,清空用户名和密码,并把焦点送回用户名输入框。

为什么值得做:Session 展示了 resetFocus(in:),它会把焦点按当前 namespace 的默认偏好重新计算。

怎么开始:读取 @Environment(\.resetFocus),在 Clear 按钮 action 里先更新表单状态,再调用 resetFocus(in: namespace)

关联 Session

评论

GitHub Issues · utterances