I was following the tutorial video to implement arrow key navigation on a LazyVGrid.
(starting at 18:50: https://developer.apple.com/videos/play/wwdc2023/10162/?time=1130)
In the code tab there is a full code sample but it omits the part where the MoveCommandDirection actually determines what the next grid items to select is.
private func selectRecipe(
_ direction: MoveCommandDirection, layoutDirection: LayoutDirection
) {
// ...
}
Considering that the LazyVGrid
actually uses an adaptive layout, what is the correct way to determine the GridItem above or below the current selected GridItem?
this is the full example the tutorial provides:
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)
}
}
private var columns: [GridItem] {
[ GridItem(.adaptive(minimum: RecipeTile.size), spacing: 0) ]
}
private func navigateToRecipe(id: Recipe.ID) {
// ...
}
private func selectRecipe(
_ direction: MoveCommandDirection, layoutDirection: LayoutDirection
) {
// ...
}
private func selectRecipe(matching characters: String) -> KeyPress.Result {
// ...
return .handled
}
}
struct RecipeTile: View {
static let size = 240.0
static let selectionStrokeWidth = 4.0
var recipe: Recipe
var isSelected: Bool
private var strokeStyle: AnyShapeStyle {
isSelected
? AnyShapeStyle(.selection)
: AnyShapeStyle(.clear)
}
var body: some View {
VStack {
RoundedRectangle(cornerRadius: 20)
.fill(.background)
.strokeBorder(
strokeStyle,
lineWidth: Self.selectionStrokeWidth)
.frame(width: Self.size, height: Self.size)
Text(recipe.name)
}
}
}
struct SelectedRecipeKey: FocusedValueKey {
typealias Value = Binding<Recipe.ID>
}
extension FocusedValues {
var selectedRecipe: Binding<Recipe.ID>? {
get { self[SelectedRecipeKey.self] }
set { self[SelectedRecipeKey.self] = newValue }
}
}
struct RecipeCommands: Commands {
@FocusedBinding(\.selectedRecipe) private var selectedRecipe: Recipe.ID?
var body: some Commands {
CommandMenu("Recipe") {
Button("Add to Grocery List") {
if let selectedRecipe {
addRecipe(selectedRecipe)
}
}
.disabled(selectedRecipe == nil)
}
}
private func addRecipe(_ recipe: Recipe.ID) { /* ... */ }
}
struct Recipe: Hashable, Identifiable {
static let examples: [Recipe] = [
Recipe(name: "Apple Pie"),
Recipe(name: "Baklava"),
Recipe(name: "Crème Brûlée")
]
let id = UUID()
var name = ""
var imageName = ""
}