WWDC Quick Look 💓 By SwiftGGTeam
Direct and reflect focus in SwiftUI

Direct and reflect focus in SwiftUI

观看原视频

Highlight

iOS 15 为 SwiftUI 引入了 @FocusState 属性包装器和 focused() 修饰符,让开发者可以程序化控制焦点移动、验证表单时自动回退到错误字段、以及通过 focusSection() 在 tvOS 上构建大范围的焦点导航目标。

核心内容

做表单类 App 时,键盘和焦点管理一直是痛点。

以前的做法是混用 UIKit 的 becomeFirstResponder()resignFirstResponder(),通过 UIViewRepresentable 桥接,代码繁琐且平台不一致。tvOS 上更痛苦——方向键的焦点跳转几乎不可控。

iOS 15 的 SwiftUI 用一套 API 解决了全平台的焦点管理问题。

程序化控制焦点

@FocusState 是一个新的属性包装器,专门用于追踪和管理焦点状态。

enum Field: Hashable {
    case email
    case password
}

struct LoginView: View {
    @FocusState private var focusedField: Field?
    @State private var email = ""
    @State private var password = ""

    var body: some View {
        VStack {
            TextField("Email", text: $email)
                .focused($focusedField, equals: .email)

            SecureField("Password", text: $password)
                .focused($focusedField, equals: .password)
        }
    }
}

03:49

关键点:

  • focusedField 是可选类型,nil 表示没有焦点在该表单区域
  • Field 枚举可以是任何 Hashable 类型,字符串、整数都可以
  • 修改 focusedField 的值,焦点会自动移动到对应视图

表单验证与焦点回退

VStack {
    TextField("Email", text: $email)
        .focused($focusedField, equals: .email)
        .border(Color.red, width: (focusedField == .email && !isEmailValid) ? 2 : 0)

    SecureField("Password", text: $password)
        .focused($focusedField, equals: .password)
}
.onSubmit {
    submittedEmail = email
    if !isEmailValid {
        focusedField = .email
    } else {
        focusedField = nil // 验证通过,收起键盘
    }
}

06:07

当用户点击键盘上的 Go 按钮,onSubmit 被触发。如果邮箱格式不对,焦点自动回到邮箱输入框,同时显示红色边框提示。验证通过后,设置 focusedField = nil,键盘自动收起。

tvOS 上的大范围焦点导航

tvOS 的焦点导航基于相邻关系。如果两个可聚焦视图不相邻,方向键无法跳转过去。

focusSection() 修饰符可以把一个容器视图标记为焦点区域,让焦点可以”跳跃”到非相邻的视图。

HStack {
    VStack {
        TextField("Email", text: $email)
        SecureField("Password", text: $password)
    }
    .focusSection() // 左侧焦点区域

    VStack {
        Image(photoName)
        Button("Browse Photos") {}
    }
    .focusSection() // 右侧焦点区域
}

09:47

加了 focusSection() 后,用户在左侧输入框按遥控器右键,焦点会直接跳到右侧的 Browse Photos 按钮,即使它们在屏幕上的距离很远。

详细内容

完整的登录表单示例

import SwiftUI
import AuthenticationServices

enum Field: Hashable {
    case email
    case password
}

struct ContentView: View {
    @FocusState private var focusedField: Field?
    @State private var email: String = ""
    @State private var password: String = ""
    @State private var submittedEmail: String = ""

    var body: some View {
        VStack(alignment: .center) {
            Text("Vacation Planner")
                .font(.custom("Baskerville-SemiBoldItalic", size: 60))

            TextField("Email", text: $email)
                .submitLabel(.next)
                .textContentType(.emailAddress)
                .keyboardType(.emailAddress)
                .padding()
                .frame(height: 50)
                .background(Color.white.opacity(0.9))
                .cornerRadius(15)
                .padding(10)
                .focused($focusedField, equals: .email)
                .border(Color.red,
                        width: (focusedField == .email && !isEmailValid) ? 2 : 0)

            SecureField("Password", text: $password)
                .submitLabel(.go)
                .textContentType(.password)
                .padding()
                .frame(height: 50)
                .background(Color.white.opacity(0.9))
                .cornerRadius(15)
                .padding(10)
                .focused($focusedField, equals: .password)

            SignInWithAppleButton(.signIn) { request in
                request.requestedScopes = [.fullName, .email]
            } onCompletion: { result in
                switch result {
                case .success:
                    print("Authorization successful.")
                case .failure(let error):
                    print("Authorization failed: " + error.localizedDescription)
                }
            }
            .frame(height: 50)
            .cornerRadius(15)
        }
        .onSubmit {
            submittedEmail = email
            if !isEmailValid {
                focusedField = .email
            } else {
                focusedField = nil
            }
        }
    }

    private var isEmailValid: Bool {
        let regex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
        let predicate = NSPredicate(format: "SELF MATCHES %@", regex)
        return submittedEmail.isEmpty || predicate.evaluate(with: submittedEmail)
    }
}

06:25

关键点:

  • .submitLabel(.next).submitLabel(.go) 控制键盘右下角的按钮文字
  • .textContentType(.emailAddress) 让系统提供自动填充建议
  • .keyboardType(.emailAddress) 弹出适合输入邮箱的键盘布局
  • .border() 的条件表达式根据焦点状态和验证结果动态显示红色边框

跨平台焦点管理

@FocusState 不限于文本输入框。任何可聚焦的视图都可以使用,包括 iOS、tvOS、watchOS 和 macOS。

struct SettingsView: View {
    @FocusState private var focusedButton: String?

    var body: some View {
        VStack {
            Button("Notifications") {}
                .focused($focusedButton, equals: "notifications")

            Button("Privacy") {}
                .focused($focusedButton, equals: "privacy")

            Button("About") {}
                .focused($focusedButton, equals: "about")
        }
    }
}

核心启发

1. 为复杂表单添加智能导航

在注册流程中,用户填完一个字段按回车后,自动将焦点移到下一个必填项。如果当前字段验证失败,焦点留在原地并显示错误提示。入口 API:@FocusState + .submitLabel() + .onSubmit()

2. 实现一键清空搜索框

在搜索界面添加一个清除按钮,点击后清空搜索文本并自动重新聚焦到搜索框,用户可以直接输入新关键词。入口 API:focusedField = .search

3. 为 tvOS 应用设计侧边栏+内容布局

focusSection() 把侧边栏和内容区标记为两个独立的焦点区域,用户用遥控器方向键可以在区域间自由跳转,无需逐个遍历中间的所有元素。入口 API:.focusSection()

4. 构建多步骤向导式界面

在设置向导中,每个步骤完成后自动将焦点移到下一步的第一个输入框。用 @FocusState 枚举表示步骤和字段的组合状态。入口 API:@FocusState + withAnimation

5. 为 macOS 应用添加快捷键导航

结合 keyboardShortcut()@FocusState,让用户按 Tab 键在表单字段间循环,按 Cmd+Enter 提交。入口 API:.keyboardShortcut(.tab) + @FocusState

关联 Session

评论

GitHub Issues · utterances