WWDC Quick Look 💓 By SwiftGGTeam
The SwiftUI cookbook for focus

The SwiftUI cookbook for focus

观看原视频

Highlight

SwiftUI 在 iOS 17、macOS Sonoma、tvOS 17 和 watchOS 10 中扩展了 Focus 系统,新增 .focusable(interactions:) 精确控制焦点交互类型、@FocusState 支持自定义数据类型、focusedSceneValue 实现跨视图数据流、focusSection() 引导方向键导航,以及 onKeyPressdefaultFocus 让键盘驱动的交互体验可以精确编程控制。

核心内容

Focus 是注意力的光标

键盘、Apple TV 遥控器和 Apple Watch 数码表冠有一个共同点:它们不提供屏幕坐标。鼠标点击时,系统知道光标在哪里;但按下键盘按键时,系统需要知道输入该发给哪个视图。

01:05

Focus 就是解决这个问题的一套机制。它像一种特殊的”注意力光标”,标记当前应该接收输入的视图。macOS 会给焦点视图加边框,watchOS 会画绿色边框,tvOS 会给焦点视图添加悬浮效果。

02:17

两种焦点交互类型

iOS 17 之前,.focusable 只有一种行为。现在你可以精确指定交互类型:

  • .focusable(interactions: .edit):用于需要持续焦点输入的控件,如文本框。点击就能获得焦点。
  • .focusable(interactions: .activate):用于将焦点作为点击替代的控件,如按钮。只有在系统开启”键盘导航”时才能通过 Tab 键获得焦点。

05:04

如果你之前在 macOS 上用了 .focusable,升级到 Sonoma 后需要检查行为是否符合预期,因为默认行为可能已经改变。

FocusState 管理焦点位置

@FocusState 让你可以观察和编程式控制焦点位置。Bool 类型用于单个视图,自定义 Hashable 类型可以管理多个视图中的焦点。

06:12

Focused Values 连接远程 UI

菜单命令、工具栏按钮等 UI 元素远离主内容区域,但它们需要知道当前焦点上下文。Focused Values 通过定义 FocusedValueKey,让数据随焦点移动自动更新到远程 UI。

07:32

Focus Sections 引导导航路径

在复杂布局中,方向键或 Tab 键的焦点移动可能不符合直觉。focusSection() 可以把一组视图标记为一个导航区域,引导焦点朝正确的方向移动。

10:03

详细内容

自定义控件的焦点交互

struct RecipeGrid: View {
    var body: some View {
        LazyVGrid(columns: [GridItem(), GridItem()]) {
            ForEach(0..<4) { _ in Capsule() }
        }
        .focusable(interactions: .edit)
    }
}

struct RatingPicker: View {
    var body: some View {
        HStack { Capsule() ; Capsule() }
            .focusable(interactions: .activate)
    }
}

05:05

关键点:

  • .focusable(interactions: .edit) 让控件在点击时获得焦点,适合文本输入类控件
  • .focusable(interactions: .activate) 让控件只在键盘导航开启时通过 Tab 获得焦点,适合按钮类控件
  • 不指定参数时,控件对所有交互类型都支持焦点
  • macOS 上已有的 .focusable 代码升级到 Sonoma 后行为可能变化,需要验证

用 FocusState 观察和控制焦点

struct GroceryListView: View {
    @FocusState private var isItemFocused
    @State private var itemName = ""

    var body: some View {
        TextField("Item Name", text: $itemName)
            .focused($isItemFocused)

        Button("Done") { isItemFocused = false }
            .disabled(!isItemFocused)
    }
}

06:12

关键点:

  • @FocusState 声明一个与焦点位置绑定的属性
  • .focused($isItemFocused) 把 TextField 与 FocusState 绑定
  • 点击”Done”按钮时设置 isItemFocused = false,焦点会从文本框移开
  • 按钮的 disabled 状态根据焦点状态动态变化

Focused Values 实现菜单命令与主内容的联动

struct SelectedRecipeKey: FocusedValueKey {
    typealias Value = Binding<Recipe>
}

extension FocusedValues {
    var selectedRecipe: Binding<Recipe>? {
        get { self[SelectedRecipeKey.self] }
        set { self[SelectedRecipeKey.self] = newValue }
    }
}

struct RecipeView: View {
    @Binding var recipe: Recipe

    var body: some View {
        VStack {
            Text(recipe.title)
        }
        .focusedSceneValue(\.selectedRecipe, $recipe)
    }
}

struct RecipeCommands: Commands {
    @FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe?

    var body: some Commands {
        CommandMenu("Recipe") {
            Button("Add to Grocery List") {
                if let selectedRecipe {
                    addRecipe(selectedRecipe)
                }
            }
            .disabled(selectedRecipe == nil)
        }
    }

    private func addRecipe(_ recipe: Recipe) { /* ... */ }
}

07:32

关键点:

  • 定义 FocusedValueKey 协议,指定值的类型
  • 扩展 FocusedValues 提供类型安全的访问属性
  • .focusedSceneValue(\.selectedRecipe, $recipe) 在当前视图有焦点时提供数据
  • @FocusedBinding 在菜单命令中读取焦点上下文的数据
  • 焦点移动时,菜单命令会自动更新

Focus Sections 改善方向键导航

struct ContentView: View {
    @State private var favorites = Recipe.examples
    @State private var selection = Recipe.examples.first!

    var body: some View {
        VStack {
            HStack {
                ForEach(favorites) { recipe in
                    Button(recipe.name) { selection = recipe }
                }
            }

            Image(selection.imageName)

            HStack {
                Spacer()
                Button("Add to Grocery List") { addIngredients(selection) }
                Spacer()
            }
            .focusSection()
        }
    }

    private func addIngredients(_ recipe: Recipe) { /* ... */ }
}

10:03

关键点:

  • .focusSection() 标记一个区域为焦点导航的目标
  • Focus section 本身不会获得焦点,但会引导焦点向最近的焦点内容移动
  • 要让 focus section 有效,它的尺寸必须大于内部内容(用 Spacer 扩展)
  • 在 tvOS 上,这解决了”从网格某行向左滑动无法到达侧边栏”的问题

编程式控制焦点移动

struct GroceryListView: View {
    @State private var list = GroceryList.examples
    @FocusState private var focusedItem: GroceryList.Item.ID?

    var body: some View {
        NavigationStack {
            List($list.items) { $item in
                HStack {
                    Toggle("Obtained", isOn: $item.isObtained)
                    TextField("Item Name", text: $item.name)
                        .onSubmit { addEmptyItem() }
                        .focused($focusedItem, equals: item.id)
                }
            }
            .defaultFocus($focusedItem, list.items.last?.id)
            .toggleStyle(.checklist)
        }
        .toolbar {
            Button(action: addEmptyItem) {
                Label("New Item", systemImage: "plus")
            }
        }
    }

    private func addEmptyItem() {
        let newItem = list.addItem()
        focusedItem = newItem.id
    }
}

11:29

关键点:

  • @FocusState 使用自定义 Hashable 类型(这里是 Item.ID)管理多个视图中的焦点
  • .focused($focusedItem, equals: item.id) 把每个 TextField 与对应的 ID 绑定
  • .defaultFocus($focusedItem, list.items.last?.id) 在屏幕首次出现时自动把焦点设到最后一个项目
  • addEmptyItem() 创建新项目后立即更新 focusedItem,焦点自动跳转到新文本框

自定义焦点控件支持键盘和数码表冠

struct RatingPicker: View {
    @Environment(\.layoutDirection) private var layoutDirection
    @Binding var rating: Rating?

    var body: some View {
        EmojiContainer { ratingOptions }
            .contentShape(.capsule)
            .focusable(interactions: .activate)
            #if os(macOS)
            .onMoveCommand { direction in
                selectRating(direction, layoutDirection: layoutDirection)
            }
            #endif
            #if os(watchOS)
            .digitalCrownRotation($digitalCrownRotation, from: 0, through: Double(Rating.allCases.count - 1), by: 1, sensitivity: .low)
            .onChange(of: digitalCrownRotation) { oldValue, newValue in
                if let rating = Rating(rawValue: Int(round(digitalCrownRotation))) {
                    self.rating = rating
                }
            }
            #endif
    }

    private var ratingOptions: some View {
        ForEach(Rating.allCases) { rating in
            EmojiView(rating: rating, isSelected: self.rating == rating) {
                self.rating = rating
            }
        }
    }
}

15:25

关键点:

  • .contentShape(.capsule) 让焦点环跟随胶囊形状,而不是默认的矩形
  • .focusable(interactions: .activate) 指定为激活类型,点击不会获得焦点
  • .onMoveCommand 处理方向键输入,在 macOS 上用左右箭头切换选项
  • .digitalCrownRotation 在 watchOS 上绑定数码表冠旋转
  • layoutDirection 环境值处理从右到左语言的布局翻转

可聚焦网格支持键盘导航

struct ContentView: View {
    @State private var recipes = Recipe.examples
    @State private var selection: Recipe.ID = Recipe.examples.first!.id
    @Environment(\.layoutDirection) private var layoutDirection

    var body: some View {
        LazyVGrid(columns: columns) {
            ForEach(recipes) { recipe in
                RecipeTile(recipe: recipe, isSelected: recipe.id == selection)
                    .id(recipe.id)
                    #if os(macOS)
                    .onTapGesture { selection = recipe.id }
                    .simultaneousGesture(TapGesture(count: 2).onEnded {
                        navigateToRecipe(id: recipe.id)
                    })
                    #else
                    .onTapGesture { navigateToRecipe(id: recipe.id) }
                    #endif
            }
        }
        .focusable()
        .focusEffectDisabled()
        .focusedValue(\.selectedRecipe, $selection)
        .onMoveCommand { direction in
            selectRecipe(direction, layoutDirection: layoutDirection)
        }
        .onKeyPress(.return) {
            navigateToRecipe(id: selection)
            return .handled
        }
        .onKeyPress(characters: .alphanumerics, phases: .down) { keyPress in
            selectRecipe(matching: keyPress.characters)
        }
    }
}

18:50

关键点:

  • .focusable() 让整个网格获得焦点,不指定交互类型时支持所有交互
  • .focusEffectDisabled() 关闭自动焦点环,因为选中状态已经传达了焦点信息
  • .focusedValue(\.selectedRecipe, $selection) 把当前选择暴露给菜单命令
  • .onMoveCommand 处理方向键,更新选中项
  • .onKeyPress(.return) 处理回车键,进入详情
  • .onKeyPress(characters: .alphanumerics) 实现首字母快速定位

核心启发

1. 给 macOS 应用加上完整的键盘导航

如果你的应用在 macOS 上运行,打开系统设置中的”键盘导航”开关,测试所有交互是否可以通过 Tab 和方向键完成。给自定义控件加上 .focusable(interactions: .activate),给容器加上 .focusSection() 引导导航路径。入口是系统设置 > 键盘 > 键盘导航。

2. 用 Focused Values 简化菜单命令

你的菜单命令(如”添加到收藏”、“删除”)需要根据当前焦点上下文决定可用状态。用 FocusedValueKey + @FocusedBinding 替代手动传递选中状态。入口是定义一个 FocusedValueKey,在主视图上用 .focusedSceneValue 提供数据,在 Commands 中用 @FocusedBinding 读取。

3. 实现 grocery list 式的自动焦点跳转

在表单或列表中,用户添加新项目后自动把焦点跳转到新输入框。用 @FocusState 绑定项目 ID,创建新项目后更新绑定值。入口是给每个输入框加上 .focused($focusedItem, equals: item.id),在添加方法中设置 focusedItem = newItem.id

4. 给 tvOS 应用添加焦点区域

tvOS 上焦点通过遥控器方向键移动。如果侧边栏和主内容之间无法直接导航,给每个区域加上 .focusSection()。入口是找到需要跨区导航的布局,在外层容器上添加 .focusSection(),确保容器尺寸大于内部内容。

5. 用 onKeyPress 实现键盘快捷键

在 macOS 和 iPadOS 上,给复杂界面添加键盘快捷键。回车键确认、Escape 取消、字母键快速定位。入口是在焦点容器上添加 .onKeyPress(.return).onKeyPress(characters:) 修饰符。

关联 Session

评论

GitHub Issues · utterances