Direct and reflect focus in SwiftUI
Accessibility 进阶 22m

SwiftUI 中的焦点直控与反射

Direct and reflect focus in SwiftUI

2021年6月10日

在 Apple 官方观看视频

一句话判断

@FocusState 是 SwiftUI 对焦点管理的正式回答——如果你做过 tvOS 或者需要键盘导航的 iPad app,这个 API 会让你从「焦点控制地狱」中解脱出来。

这场 Session 讲了什么

焦点管理(Focus Management)一直是 SwiftUI 的短板。在 iOS 15 之前,控制哪个输入框获得焦点需要各种 hack,tvOS 上更是痛苦——方向键的焦点跳转行为几乎不可控。iOS 15 引入了 @FocusStatefocusedValue 两个核心 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 精确控制。
WWDC 2021