Highlight
SwiftUI extends the Focus system in iOS 17, macOS Sonoma, tvOS 17, and watchOS 10 with new
.focusable(interactions:)Precise control over focus interaction types,@FocusStateSupport custom data types,focusedSceneValueImplement cross-view data flow,focusSection()Guided directional pad navigation, andonKeyPressanddefaultFocusMake keyboard-driven interactive experiences precisely programmable.
Core Content
Focus is the attention cursor
The keyboard, Apple TV remote, and Apple Watch Digital Crown have one thing in common: they don’t provide screen coordinates. When the mouse clicks, the system knows where the cursor is; but when a keyboard key is pressed, the system needs to know which view the input should be sent to.
(01:05)
Focus is a set of mechanisms to solve this problem. It acts like a special “attention cursor” that marks the current view that should receive input. macOS will add a border to the focus view, watchOS will draw a green border, and tvOS will add a floating effect to the focus view.
(02:17)
Two focus interaction types
Before iOS 17,.focusableThere is only one act. Now you can specify the interaction type precisely:
.focusable(interactions: .edit): Used for controls that require continuous focus input, such as text boxes. Click to get focus. -.focusable(interactions: .activate): Used for controls that use focus as a click alternative, such as buttons. The Tab key can only gain focus when Keyboard Navigation is turned on.
(05:04)
If you have used it on macOS before.focusable, you need to check whether the behavior is as expected after upgrading to Sonoma, as the default behavior may have changed.
FocusState manages focus position
@FocusStateAllows you to observe and programmatically control focus position. The Bool type is used for a single view, and the custom Hashable type can manage focus in multiple views.
(06:12)
Focused Values Connect to remote UI
UI elements such as menu commands and toolbar buttons are located away from the main content area, but they need to know the current focus context.Focused Valuesby definitionFocusedValueKey, allowing data to automatically update to the remote UI as focus moves.
(07:32)
Focus Sections guide navigation path
In complex layouts, arrow key or Tab key focus movement may not be intuitive.focusSection()A group of views can be marked as a navigation area to guide the focus in the right direction.
(10:03)
Detailed Content
Focus interaction of custom controls
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)
Key points:
.focusable(interactions: .edit)Let the control gain focus when clicked, suitable for text input controls -.focusable(interactions: .activate)Let the control only gain focus through Tab when keyboard navigation is enabled, suitable for button controls- When no parameters are specified, the control supports focus for all interaction types
- already on macOS
.focusableThe behavior of the code may change after upgrading to Sonoma and needs to be verified
Use FocusState to observe and control focus
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)
Key points:
@FocusStateDeclare a property bound to the focus position -.focused($isItemFocused)Bind TextField to FocusState- Set when “Done” button is clicked
isItemFocused = false, the focus will move away from the text box - button
disabledStatus changes dynamically based on focus status
Focused Values realizes the linkage between menu commands and main content
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)
Key points:
- Definition
FocusedValueKeyProtocol, specifying the type of value - extension
FocusedValuesProvide type-safe access properties -.focusedSceneValue(\.selectedRecipe, $recipe)Provide data while the current view has focus -@FocusedBindingRead focus context data in menu command - Menu commands automatically update when focus moves
Focus Sections Improve arrow key navigation
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)
Key points:
.focusSection()Mark an area as the target of focus navigation- The Focus section itself will not gain focus, but will guide the focus to move to the nearest focused content
- For a focus section to be effective, its size must be larger than the inner content (using
Spacerextension) - On tvOS, this fixes the issue where swiping left from a row in the grid cannot reach the sidebar.
Programmatically control focus movement
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)
Key points:
@FocusStateUse customHashableType (here isItem.ID) manages focus in multiple views -.focused($focusedItem, equals: item.id)Bind each TextField to the corresponding ID -.defaultFocus($focusedItem, list.items.last?.id)Automatically set focus to the last item when the screen first appears -addEmptyItem()Update immediately after creating a new projectfocusedItem, the focus automatically jumps to the new text box
Custom focus control supports keyboard and digital crown
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)
Key points:
.contentShape(.capsule)Make the focus ring follow the capsule shape instead of the default rectangle -.focusable(interactions: .activate)Specified as activation type, click will not get focus -.onMoveCommandHandle arrow key input, switching options with left and right arrows on macOS -.digitalCrownRotationBind Digital Crown rotation on watchOS- use
layoutDirectionEnvironment values handle layout flipping for right-to-left languages
Focusable grid supports keyboard navigation
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)
Key points:
.focusable()Gives the entire grid focus and supports all interactions when no interaction type is specified. -.focusEffectDisabled()Turn off the automatic focus ring because the selected state already conveys focus information -.focusedValue(\.selectedRecipe, $selection)Expose the current selection to a menu command -.onMoveCommandHandle arrow keys and update selected items -.onKeyPress(.return)Handle the Enter key and enter details -.onKeyPress(characters: .alphanumerics)Achieve quick positioning of initial letters
Core Takeaways
1. Add complete keyboard navigation to macOS applications
If your app is running on macOS, turn on the “Keyboard Navigation” switch in system settings and test that all interactions can be completed using the Tab and arrow keys. Add custom controls.focusable(interactions: .activate), add to the container.focusSection()Guide navigation path. The entrance is System Settings > Keyboard > Keyboard Navigation.
2. Use Focused Values to simplify menu commands
Your menu commands (such as “Add to Favorites”, “Delete”) need to be available based on the current focus context. useFocusedValueKey + @FocusedBindingOverride manually passing selected state. The entrance is to define aFocusedValueKey, use on the main view.focusedSceneValueProvide data inCommandsChinese use@FocusedBindingRead.
3. Implement grocery list-style automatic focus jump
In a form or list, after the user adds a new item, the focus automatically jumps to the new input box. use@FocusStateBind the project ID and update the binding value after creating a new project. The entrance is to add to each input box.focused($focusedItem, equals: item.id), set in the add methodfocusedItem = newItem.id。
4. Add focus area to tvOS applications
On tvOS, the focus is moved using the remote control arrow keys. If there is no direct navigation between the sidebar and main content, add.focusSection(). The entrance is to find the layout that requires cross-area navigation and add it to the outer container.focusSection(), ensuring that the container size is larger than the inner content.
5. UseonKeyPressImplement keyboard shortcuts
On macOS and iPadOS, add keyboard shortcuts to complex interfaces. Enter key to confirm, Escape to cancel, letter key to quickly locate. The entrance is added on the focus container.onKeyPress(.return)or.onKeyPress(characters:)modifier.
Related Sessions
- What’s new in SwiftUI — SwiftUI annual new feature summary
- The SwiftUI cookbook for navigation — Detailed explanation of SwiftUI navigation mode
- Meet watchOS 10 — New Apple Watch features interact with the Digital Crown
- Design for tvOS — tvOS focus-driven design guide
Comments
GitHub Issues · utterances