tvOS 焦点引擎:Siri Remote 的 SwiftUI 模式
Apple TV 是唯一没有触摸界面的 Apple 平台。用户通过 Siri Remote 上的方向滑动和按键进行导航,每一次交互都要经过焦点引擎:该系统会根据几何位置、层级结构以及开发者声明的焦点结构来决定下一个接收焦点的元素1。tvOS 上的 SwiftUI 提供了一套专注(请原谅这个双关)的词汇用于与该引擎协作:.focusable、@FocusState、.focused、.focusSection、.prefersDefaultFocus 以及 .focusEffectDisabled。采用这套词汇的应用会显得原生地道;与之对抗的应用则会让用户体验到一个拒绝按预期方向导航的遥控器。
本文走遍焦点引擎的API表面,介绍那些真正能落地的模式。整体框架是”引擎假设了什么,以及 SwiftUI 如何让你与之协作”,因为在 iOS 上通过点击和滚动行得通的焦点设计,在 tvOS 上往往会失效;而本系列的Apple 平台矩阵一文也指出,tvOS 只有具备焦点感知的 UI 才能赢得自己的位置。
TL;DR
- 焦点引擎按几何位置解析焦点:在滑动方向上选择最近的可聚焦视图1。应用通过声明可聚焦视图、焦点分区和默认焦点目标来与之协作。
@FocusState(配合.focused(_:equals:))是 SwiftUI 中用于程序化焦点控制的原语。同一个属性包装器在 iOS、macOS、watchOS 和 tvOS 上都能用,但 tvOS 才是它真正发挥价值的地方2。.focusSection()把多个可聚焦视图分组为一个用于跨分区导航的焦点目标,然后让引擎在分区内部进行选择3。可用于按钮行、卡片网格、侧边栏分区。.prefersDefaultFocus(_:in:)声明用户进入某个上下文(屏幕、弹出层、标签页)时哪个视图接收焦点。配合@Namespace来限定默认焦点的作用域4。- 系统焦点效果(聚焦视图周围放大的高亮)是自动的。仅在实现自定义焦点视觉时才使用
.focusEffectDisabled()关闭它;否则平台原生效果就是正确的选择。
焦点引擎如何决定
焦点引擎处理来自 Siri Remote 的滑动输入,并通过层级搜索来解析”焦点接下来去哪里?”1:
- 读取滑动方向(上、下、左、右)。
- 在当前焦点上下文中,找出相对于当前聚焦视图、位于该方向上的可聚焦视图。
- 沿滑动轴选择几何上最近的那一个(带有轻微偏向,倾向于保持与当前视图中心对齐)。
- 如果该方向上没有可聚焦视图,滑动会被消费但焦点不会移动。
由此可见:可聚焦视图的视觉布局与其逻辑层级同等重要。两个对角偏移的按钮会产生模糊的导航;两个垂直对齐的按钮则带来可预测的上下移动。HIG 推荐的网格和列表模式是先对齐,再装饰。
应用通过 SwiftUI 的焦点修饰符参与到引擎中。默认行为是:具有明确交互意图的视图(Button、NavigationLink、TextField)是可聚焦的;静态视图(Text、Image、VStack 等容器视图)则不是。
让自定义视图可聚焦
.focusable() 修饰符将视图标记为焦点目标5。可选的布尔参数用于条件化可聚焦性:
struct PosterCard: View {
let movie: Movie
@FocusState private var isFocused: Bool
var body: some View {
VStack {
Image(movie.posterName)
.resizable()
.aspectRatio(2/3, contentMode: .fit)
Text(movie.title)
.font(.headline)
}
.focusable(true)
.focused($isFocused)
.scaleEffect(isFocused ? 1.1 : 1.0)
.animation(.spring(), value: isFocused)
}
}
该视图成为引擎可以落点的焦点目标。这种模式适用于可点击的卡片、自定义按钮,以及任何应当接收用户注意的复合视图。如果不加 .focusable(),Image + Text 的组合会被引擎跳过。
用 @FocusState 和 .focused(_:equals:) 进行程序化控制
当应用需要主动指定焦点(在导航跳转之后、搜索提交之后、模态关闭之后),@FocusState 就是 SwiftUI 的原语2:
struct LoginView: View {
enum Field { case username, password, submit }
@FocusState private var focusedField: Field?
@State private var username = ""
@State private var password = ""
var body: some View {
VStack {
TextField("Username", text: $username)
.focused($focusedField, equals: .username)
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
Button("Sign In") { /* ... */ }
.focused($focusedField, equals: .submit)
}
.onAppear {
focusedField = .username
}
}
}
@FocusState 枚举值跟踪当前哪个字段被聚焦;以编程方式赋一个新值就会把焦点移到对应视图。约定使用 Hashable 枚举 case;多个字段共用同一个 case 值会引发歧义。
对于单个可聚焦视图,@FocusState var isFocused: Bool 加 .focused($isFocused) 是更简单的形式。布尔变体适合回答”这个视图是否被聚焦?”;枚举变体则适合”这一组里哪个视图被聚焦?”。
用 .focusSection() 进行分组
不使用 .focusSection() 时,每个可聚焦视图都在同一层级参与引擎的几何搜索。加上它之后,容器变成一个焦点组:进出该分区是一次决策,分区内部的导航是另一次决策3。需要注意的是,.focusSection() 仅适用于 tvOS 和 macOS;在 iOS、iPadOS、watchOS 或 visionOS 上没有效果。
HStack {
VStack {
Button("Settings") { ... }
Button("Profile") { ... }
Button("Logout") { ... }
}
.focusSection()
VStack {
ContentList(items: items)
}
.focusSection()
}
两个 VStack 作为整体单元变得可导航。用户从侧边栏向右滑动会落在内容区域;进入之后,引擎处理该区域内部的导航。如果不使用 .focusSection(),从侧边栏按钮滑出可能会落在某个恰好几何位置最近的任意内容项上,给人一种随机的体验。
正确的模式是:每个具有内部焦点结构的 UI 区域(侧边栏、卡片网格、标签栏、分页控件)都给其容器加上 .focusSection() 修饰符。引擎随后会在宏观层面跨分区导航,在微观层面进行分区内导航。
用 .prefersDefaultFocus(_:in:) 设置初始焦点
当一个屏幕出现或弹出层打开时,总得有某个东西获得初始焦点。如果没有明确的指引,引擎会选择布局中第一个可聚焦视图,而这往往是错的(选了返回按钮而不是主操作,选了某个不起眼的列表单元而不是播放按钮)4。
struct MovieDetailView: View {
let movie: Movie
@Namespace private var detailNamespace
var body: some View {
VStack {
HStack {
Button("Back") { ... }
Spacer()
}
PosterImage(movie: movie)
Button("Play") { ... }
.prefersDefaultFocus(in: detailNamespace)
Button("Add to Watchlist") { ... }
}
.focusScope(detailNamespace)
}
}
@Namespace 加 .focusScope() 定义了焦点边界,.prefersDefaultFocus(in:) 在该作用域内声明首选的初始焦点。屏幕出现时,焦点会落在 Play 上。
这种模式适用于任何用户进入时带着明确”首先要做什么”预期的视图:电影详情页上的 Play、登录页上的 Sign In、引导页上的 Get Started。
自定义焦点效果(以及何时禁用默认效果)
系统焦点效果是聚焦视图周围那种带柔和边缘、逐渐放大的光晕。它会让视图轻微缩放、添加细微阴影,并以平台标准动效进行动画过渡。对绝大多数应用而言,默认效果都是正确的;它与所有其他 tvOS 应用保持一致,让用户能够习得平台的视觉词汇。
对于需要自定义焦点视觉的应用(品牌专属的光晕、内容感知效果、与默认效果冲突的焦点环),.focusEffectDisabled() 可以放弃系统处理6:
Button {
play(movie)
} label: {
PosterImage(movie: movie)
.overlay(focusBorder)
.scaleEffect(isFocused ? 1.05 : 1.0)
}
.focusEffectDisabled()
.focused($isFocused)
自定义视图需要负责在视觉上指示焦点;系统不再介入。代价是:每一种焦点视觉都必须由应用自行设计和实现,而不能继承现成的。对绝大多数应用来说,系统效果就是正确的选择。
常见的 tvOS 焦点失败模式
三种会带来糟糕 tvOS 体验的模式:
按钮无法接收焦点。 一个被渲染为 HStack { Image; Text } 但未加 .focusable() 的自定义按钮,对引擎来说是不可见的。Siri Remote 的滑动会跳过它。修复方式:用 Button 包裹交互内容(默认就提供焦点参与),或显式应用 .focusable()。
焦点陷阱。 某个视图能接收焦点但没有任何离开路径(左右上下都没有可聚焦的同级视图,也没有 Menu 按钮的退出方式),用户被困在原地。修复方式:每个焦点上下文都应有明确的退出路径。.focusSection() 模式有所帮助,因为它给引擎提供了一个可供逃离的单元。
默认焦点落在错误的元素上。 电影详情页打开时焦点落在 Back 而不是 Play,这是用户每次进入都要付出的摩擦。修复方式:在主操作上声明 .prefersDefaultFocus(in:)。
自定义焦点效果不具备无障碍性。 一个仅靠 1pt 低对比度色边的焦点环无法满足无障碍要求。系统焦点效果是高对比度且经过动效测试的;自定义替代方案需要同等程度的考量。本系列的无障碍即平台一文涵盖了更宏观的原则。
tvOS 何时配得上自己的位置
本系列的Apple 平台矩阵一文指出,tvOS 是装机量相对于 iOS 最小的平台,应用需要真正具备”放松靠坐”或”沙发模式”的使用场景,才值得对其进行工程投入。焦点引擎正是这份投入的一部分:不尊重焦点词汇的 tvOS 应用,感觉就像被拉伸到电视上的 iPad 应用。这份投入是真实的,因为API表面是真实的;这份工程工作是有意义的,因为引擎确实在决定焦点的去向。
那些赢得自己 tvOS 位置的应用通常具备三个特征: 1. 以观看电视的距离消费内容。 流媒体、照片幻灯片、手柄驱动的游戏。 2. 稀疏的交互模型。 每屏几个主要操作,通过方向输入进行导航。 3. 靠坐的使用场景。 用户坐在沙发上,可能正在用另一台设备多任务处理,可能只是半看半玩。
对于这些类别的应用,焦点引擎的投入是正确的。对于不属于这些类别的应用(生产力工具、精细化创作类应用、任何文本输入密集的应用),正确的选择就是跳过 tvOS,正如矩阵那篇文章所建议的那样。
该模式对 tvOS 应用意味着什么
三点要点。
-
把焦点意图融入布局,而不是事后修补。 用户从哪里开始?从那里能去哪里?主操作是什么?在 tvOS 上设计屏幕,要先考虑焦点流,再考虑视觉构图。视觉随焦点而动。
-
对任何具有内部结构的区域积极使用
.focusSection()。 默认的几何导航在网格、侧边栏、标签栏中往往是错的。这个分区修饰符代码量很小,带来的差别却很大。 -
除非有真正的理由替换,否则保留系统焦点效果。 自定义焦点视觉意味着真实的工程工作,加上无障碍工作,再加上跨所有主题的测试。系统效果是正确的默认;只有当设计确实需要自定义处理时,才使用
.focusEffectDisabled()。
完整的 Apple 生态系列:类型化的 App Intents;MCP 服务器;路由问题;Foundation Models;运行时与工具LLM之分;三个表面;单一事实来源模式;两个 MCP 服务器;面向 Apple 开发的 hooks;Live Activities;watchOS 运行时;SwiftUI 内部;RealityKit 的空间心智模型;SwiftData 的 schema 纪律;Liquid Glass 模式;多平台发布;平台矩阵;Vision 框架;Symbol Effects;Core ML 推理;Writing Tools API;Swift Testing;Privacy Manifest;无障碍即平台;SF Pro 字体系统;visionOS 空间模式;Speech 框架;SwiftData 迁移;我拒绝写的内容。系列入口在 Apple 生态系列。如需更广义的 iOS 配合 AI 智能体的语境,请参阅 iOS Agent 开发指南。
FAQ
.focusable() 在 iOS 上有效吗?
有效,但它在 iOS 上的行为针对的是键盘和指针交互(蓝牙键盘、iPadOS 指针、iPad Magic Keyboard),而不是 tvOS 所使用的焦点引擎驱动的导航。同一份代码可以跨平台复用;但用户层面的交互不同。在 tvOS 上,.focusable() 是主路径。在 iOS 上,它是面向无障碍的辅助手段。
.focusable() 和 Button 有什么区别?
Button 是更高层的构造,包含了可聚焦性、操作处理、系统按钮样式以及无障碍特征。.focusable() 是更底层的标记,只是把视图变成焦点目标。当视图在逻辑上就是按钮时,使用 Button;当你在构建一个不符合按钮心智模型的自定义交互视图(海报卡片、网格中的方块)时,使用 .focusable()。
可以有多个 .prefersDefaultFocus 声明吗?
可以,通过 @Namespace 来划分作用域。每个焦点作用域都可以有自己的首选默认。该模式适用于嵌套上下文(屏幕中的弹出层、侧边栏中的标签页):每个作用域选择自己的初始焦点。
在包含大量项目的列表中应该如何处理焦点?
SwiftUI 中的 List 默认就是可聚焦的;引擎会自动处理跨单元的上下导航。对于自定义的类列表布局,将每个单元包裹在 Button 中或对其应用 .focusable(),然后把整个列表放进一个 .focusSection() 内,这样引擎就会把列表作为一个整体单元来对待,与其他 UI 区域并列。
Menu 按钮在焦点模型中起什么作用?
Siri Remote 的 Menu 按钮在 tvOS 上是关闭/返回操作。它会弹出导航栈、退出模态、返回上一级上下文。SwiftUI 通过 NavigationStack 和标准模态关闭机制自动处理它;应用通常不会去拦截它。如果需要自定义关闭逻辑,onExitCommand 视图修饰符可以捕获该按键。
这与本系列其他平台文章是什么关系?
tvOS 焦点引擎是平台专属的导航表面,与 visionOS 的注视加捏合(详见 visionOS 空间模式)以及 iOS 的点击加滚动并列。每个平台都有自己的输入隐喻;本系列的Apple 平台矩阵一文指出,纳入某个平台就要尊重该隐喻,而焦点引擎正是 tvOS 所要求的那种隐喻。
参考资料
-
Apple Developer:App Programming Guide for tvOS, Controlling the User Interface with the Apple TV Remote。焦点引擎模型与几何解析规则。 ↩↩↩
-
Apple Developer Documentation:
@FocusState。用于在 SwiftUI 各平台间跟踪并以编程方式指定焦点的属性包装器。 ↩↩ -
Apple Developer Documentation:
focusSection()。将可聚焦后代视图分组为单一焦点目标、用于跨分区导航的视图修饰符。 ↩↩ -
Apple Developer Documentation:
prefersDefaultFocus(_:in:)与focusScope(_:)。默认焦点声明配合命名空间限定的焦点边界。 ↩↩ -
Apple Developer Documentation:
focusable(_:)。将视图标记为焦点目标的视图修饰符,可选条件化布尔参数。 ↩ -
Apple Developer Documentation:
focusEffectDisabled(_:)。系统焦点效果的退出选项(Bool 默认true);需要时与自定义焦点视觉配合使用。 ↩