SwiftUI 中的焦点直控与反射
Direct and reflect focus in SwiftUI
2021年6月10日
一句话判断
@FocusState 是 SwiftUI 对焦点管理的正式回答——如果你做过 tvOS 或者需要键盘导航的 iPad app,这个 API 会让你从「焦点控制地狱」中解脱出来。
这场 Session 讲了什么
焦点管理(Focus Management)一直是 SwiftUI 的短板。在 iOS 15 之前,控制哪个输入框获得焦点需要各种 hack,tvOS 上更是痛苦——方向键的焦点跳转行为几乎不可控。iOS 15 引入了 @FocusState 和 focusedValue 两个核心 API,完整解决了这个问题。
@FocusState 是一个属性包装器,类似于 @State,但专门用于管理焦点状态。你可以把它绑定到一个枚举值或者布尔值上,通过修改这个值来控制焦点。focused() 修饰符把某个视图注册为「可以被聚焦的」目标。
Session 还介绍了 FocusScope 的概念。在 tvOS 上,你可以用 focusScope() 修饰符创建焦点作用域,控制方向键在不同区域之间的跳转逻辑。这对于 tvOS 的侧边栏+内容区域布局至关重要。
值得深挖的点
焦点状态与表单验证的配合
一个常见的使用场景是表单验证失败后自动聚焦到出错的字段。用 @FocusState 绑定到一个枚举上,每个枚举值对应一个输入框。验证失败时,把 @FocusState 的值设为对应字段,键盘会自动弹出并聚焦到那个输入框。这比 UIKit 中 becomeFirstResponder 的方式更声明式。
但要注意,@FocusState 的赋值是异步生效的。如果你在同一个函数中先修改了 @State(触发视图刷新),再修改 @FocusState,焦点可能不会跳转到预期位置。建议在 DispatchQueue.main.async 或者 withAnimation 的 completion 中设置焦点。
tvOS 焦点引擎的工作原理
tvOS 的焦点引擎和 iOS 完全不同。iOS 的焦点本质上是键盘响应链,而 tvOS 的焦点是空间性的——系统根据视图在屏幕上的位置来决定方向键按下时跳转到哪个视图。@FocusState 在 tvOS 上可以精确控制这个行为,但需要配合 prefersDefaultFocus 修饰符来设置初始焦点。
代码片段
// 使用 @FocusState 管理表单焦点
struct LoginFormView: View {
enum Field: Hashable {
case username
case password
}
@State private var username = ""
@State private var password = ""
@FocusState private var focusedField: Field?
var body: some View {
Form {
TextField("用户名", text: $username)
.focused($focusedField, equals: .username)
.submitLabel(.next)
SecureField("密码", text: $password)
.focused($focusedField, equals: .password)
.submitLabel(.go)
}
.onSubmit {
switch focusedField {
case .username:
focusedField = .password // 用户名提交后跳到密码
case .password:
performLogin()
case .none:
break
}
}
}
func performLogin() {
if username.isEmpty {
focusedField = .username // 验证失败,聚焦到用户名
}
}
}
// tvOS 焦点作用域控制
struct TVMainView: View {
@FocusState private var isInSidebar: Bool
var body: some View {
NavigationView {
// 侧边栏焦点作用域
List(menuItems) { item in
NavigationLink(item.title, destination: DetailView())
}
.focusScope("sidebar") // 定义焦点作用域
.focused($isInSidebar, equals: true)
// 内容区域
DetailView()
.focusScope("content")
}
}
}
// 监听焦点变化
struct FocusAwareTextField: View {
let label: String
@Binding var text: String
@FocusState private var isFocused: Bool
var body: some View {
TextField(label, text: $text)
.focused($isFocused)
.onChange(of: isFocused) { focused in
if focused {
print("获得焦点: \(label)")
} else {
print("失去焦点: \(label)")
}
}
.padding(8)
.background(isFocused ? Color.blue.opacity(0.1) : Color.clear)
}
}
最佳实践
对于复杂的表单,把焦点状态枚举定义在 ViewModel 中而不是 View 中。这样验证逻辑和焦点跳转逻辑可以放在一起管理,不需要通过 Binding 回传给 View。
在 tvOS 上,焦点调试非常困难(没有触摸屏可以手动触发焦点变化)。建议在开发阶段用 onFocusChange 打印焦点日志,或者在 tvOS 模拟器中用遥控器调试模式逐帧追踪焦点跳转。
还有什么值得关注
@FocusState配合@Environment可以实现跨视图层级的焦点控制。submitLabel修饰符可以控制键盘上 return 键的文案(下一步、完成、搜索等)。- iPadOS 上外接键盘时的 Tab 导航现在也可以通过
@FocusState精确控制。