I am trying to construct a view where they are multiple buttons, and selecting each button can show a different List view. I want to be able to select the controls with just the keyboard, so if the 'focus' is on the buttons, then I can move left or right with the arrow keys, pressing the 'down' key should shift the focus to the List that is currently visible (and select the first row), and if the focus is on the list, then pressing 'up' should shift the focus to the buttons level. I have it working such that left-right arrow keys work for selecting between the buttons, which switches the List views as required, but the 'down' and 'up' keys don't seem to shift focus between the list and the selected button.
In my implementation, I kept 2 top-level @FocusState
s, which are passed into subviews as bindings. I keep a view-model to hold the current selected button. I update the list's @FocusState.Binding
in the button's key-handler, but it doesn't seem to have any effect. The 'list' doesn't get focused (unless I manually select it using the pointed). Also, if the 'list' is focused on the first row and I press the 'up' button, the handler updates the button focus state (through the @FocusState.Binding
) but that doesn't seem to affect anything either.
This is the full code:
public enum ButtonTab: Int, CaseIterable, Hashable, Identifiable {
public var id: Self {
return self
}
public var buttonTitle: String {
switch self {
case .one: return "One"
case .two: return "Two"
case .three: return "Three"
}
}
case one, two, three
}
public class ContentViewModel: NSObject, ObservableObject {
@Published public var selectedButton: ButtonTab? = ButtonTab.one
public func rightArrayKeyForSelectedTab(){
DispatchQueue.main.async {
switch self.selectedButton {
case .one:
self.selectedButton = .two
case .two:
self.selectedButton = .three
case .three:
self.selectedButton = nil
case .none:
self.selectedButton = nil
}
}
}
public func leftArrayKeyForSelectedTab(){
DispatchQueue.main.async {
switch self.selectedButton {
case .one:
self.selectedButton = nil
case .two:
self.selectedButton = .one
case .three:
self.selectedButton = .two
case .none:
self.selectedButton = nil
}
}
}
}
struct ButtonTagsContentView: View {
// top-level focus states
@FocusState var buttonFocusState: ButtonTab?
@FocusState var listFocusState: ButtonTab?
@ObservedObject var viewModel: ContentViewModel = ContentViewModel()
var body: some View {
VStack {
HStack {
ButtonsView(viewModel: viewModel, bindedFocusButtonState: $buttonFocusState, bindedListFocusState: $listFocusState)
}
.padding()
if viewModel.selectedButton == .one {
ListOneView(viewModel: viewModel, bindedFocusButtonState: $buttonFocusState, bindedListFocusState: $listFocusState)
.focused($listFocusState, equals: .one)
} else if viewModel.selectedButton == .two {
ListTwoView(viewModel: viewModel, bindedFocusButtonState: $buttonFocusState, bindedListFocusState: $listFocusState)
.focused($listFocusState, equals: .two)
} else if viewModel.selectedButton == .three {
ListThreeView(viewModel: viewModel, bindedFocusButtonState: $buttonFocusState, bindedListFocusState: $listFocusState)
.focused($listFocusState, equals: .three)
}
}
.onChange(of: listFocusState) {
// This doesn't get called
if let listFocusState {
print("ContentView: listFocusChange called: \(listFocusState)")
} else {
print("ContentView: listFocusChange to nil")
}
}
}
}
struct ButtonsView: View {
@ObservedObject var viewModel: ContentViewModel
@FocusState.Binding var bindedFocusButtonState: ButtonTab?
@FocusState.Binding var bindedListFocusState: ButtonTab?
var body: some View {
HStack {
ForEach(ButtonTab.allCases, id: \.self) { button in
Button {
print("ButtonsView: \(button.buttonTitle) selected")
bindedFocusButtonState = button
viewModel.selectedButton = button
} label: {
Text(button.buttonTitle)
.underline(viewModel.selectedButton == button)
}
.padding()
.bold(viewModel.selectedButton == button)
.focusable()
.focused($bindedFocusButtonState, equals: button)
//.focusEffectDisabled()
.onKeyPress(keys: [.downArrow]) { press in
print("Down Arrow key pressed on Button - \(viewModel.selectedButton!.buttonTitle)")
bindedListFocusState = viewModel.selectedButton // doesn't seem to affect anything
bindedFocusButtonState = nil
return .handled
}
.onKeyPress(keys: [.rightArrow]) { press in
print("Right Arrow key pressed on Button")
viewModel.rightArrayKeyForSelectedTab()
return .handled
}
.onKeyPress(keys: [.leftArrow]) { press in
print("Left Arrow key pressed on Button")
viewModel.leftArrayKeyForSelectedTab()
return .handled
}
}
}
.onChange(of: bindedFocusButtonState) {
if let value = bindedFocusButtonState {
print("\(value) selected")
}
}
}
}
struct ListOneView: View {
@State private var items1: [TimedItem] = [
TimedItem(number: 1, timestamp: "2024-11-20 10:00"),
TimedItem(number: 2, timestamp: "2024-11-20 11:00"),
TimedItem(number: 3, timestamp: "2024-11-20 12:00")
]
@ObservedObject var viewModel: ContentViewModel
@FocusState.Binding var bindedFocusButtonState: ButtonTab?
@FocusState.Binding var bindedListFocusState: ButtonTab?
@State var selectedItem: TimedItem.ID?
var body: some View {
List(items1, selection: $selectedItem) { item in
ContentListItemView(item: item)
}
.onChange(of: bindedListFocusState) {
if let bindedListFocusState {
print("ListOneView - bindedListFocusState has changed to \(bindedListFocusState)")
} else {
print("ListOneView - bindedListFocusState has changed to nil")
}
}
.onKeyPress(keys: [.upArrow]) { press in
if let selectedItem, selectedItem == items1.first?.id {
print("List1: first item selected, and up key pressed")
bindedFocusButtonState = viewModel.selectedButton // doesn't work
return .handled
}
return .ignored
}
}
}
struct ListTwoView: View {
@State private var items: [TimedItem] = [
TimedItem(number: 4, timestamp: "2025-01-20 10:00"),
TimedItem(number: 5, timestamp: "2025-01-20 11:00"),
TimedItem(number: 6, timestamp: "2025-01-20 12:00")
]
@ObservedObject var viewModel: ContentViewModel
@FocusState.Binding var bindedFocusButtonState: ButtonTab?
@FocusState.Binding var bindedListFocusState: ButtonTab?
@FocusState var listHasFocus: Bool
@State var selectedItem: TimedItem.ID?
var body: some View {
List(items, selection: $selectedItem) { item in
ContentListItemView(item: item)
}
.focused($listHasFocus)
.onChange(of: listHasFocus) {
print("ListTwoView focus has changed to \(listHasFocus)")
if listHasFocus == true && selectedItem == nil {
selectedItem = items.first?.id
}
}
.onKeyPress(keys: [.upArrow]) { press in
if let selectedItem, selectedItem == items.first?.id {
print("List2: first item selected, and up key pressed")
bindedFocusButtonState = viewModel.selectedButton // doesn't work
return .handled
}
return .ignored
}
}
}
struct ListThreeView: View {
@State private var items: [TimedItem] = [
TimedItem(number: 7, timestamp: "2025-02-02 10:00"),
TimedItem(number: 8, timestamp: "2025-02-02 11:00"),
TimedItem(number: 9, timestamp: "2025-02-02 12:00")
]
@ObservedObject var viewModel: ContentViewModel
@FocusState.Binding var bindedFocusButtonState: ButtonTab?
@FocusState.Binding var bindedListFocusState: ButtonTab?
@FocusState var listHasFocus: Bool
@State var selectedItem: TimedItem.ID?
var body: some View {
List(items, selection: $selectedItem) { item in
ContentListItemView(item: item)
}
.onKeyPress(keys: [.upArrow]) { press in
if let selectedItem, selectedItem == items.first?.id {
print("List3: first item selected, and up key pressed")
bindedFocusButtonState = viewModel.selectedButton // doesn't work
return .handled
}
return .ignored
}
}
}
struct ButtonsTagsContentView_Previews: PreviewProvider {
static var previews: some View {
ButtonTagsContentView()
}
}
struct TimedItem: Identifiable {
let id = UUID()
let number: Int
let timestamp: String
}
The 'lists' are a little verbose, but just for illustration purpose.
Any ideas for what I'm doing wrong, and/or what I should do instead?
EDIT: Ok wait, this seems to work fine when running the application, but not in SwiftUI Preview. Trying to verify.