From 612e1c8f9b1fe2afb4c353b4f228a8ebebd73a68 Mon Sep 17 00:00:00 2001 From: Kavan Brandon Date: Wed, 1 Oct 2025 08:53:49 -0700 Subject: [PATCH] [iOS] Add Support for List Item Focus Area --- CHANGELOG.md | 2 + ...KeyboardNavigationDemoViewController.swift | 127 ++++++++++++++++++ .../Demos/DemosRootViewController.swift | 8 ++ ListableUI/Sources/Behavior.swift | 58 +++++++- .../Sources/ListView/ListView.Delegate.swift | 16 ++- ListableUI/Sources/ListView/ListView.swift | 13 ++ ListableUI/Tests/BehaviorTests.swift | 31 +++++ 7 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 Development/Sources/Demos/Demo Screens/KeyboardNavigationDemoViewController.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index e4af49b01..a54a23a0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- Added support for keyboard focus navigation on iOS with `Behavior.FocusConfiguration`. This enables users to navigate list items using keyboard input (Tab, Arrow keys, Return/Space) for improved accessibility and external keyboard support. + ### Removed ### Changed diff --git a/Development/Sources/Demos/Demo Screens/KeyboardNavigationDemoViewController.swift b/Development/Sources/Demos/Demo Screens/KeyboardNavigationDemoViewController.swift new file mode 100644 index 000000000..fcc48000e --- /dev/null +++ b/Development/Sources/Demos/Demo Screens/KeyboardNavigationDemoViewController.swift @@ -0,0 +1,127 @@ +// +// KeyboardNavigationDemoViewController.swift +// Development +// +// Created by Listable Demo on 12/30/24. +// + +import UIKit +import ListableUI + +final class KeyboardNavigationDemoViewController: ListViewController { + + private var allowsFocus = true + private var selectionFollowsFocus = true + + override func viewDidLoad() { + super.viewDidLoad() + + self.title = "Keyboard Navigation Focus" + + // Add toggle buttons to the navigation bar + let toggleFocusButton = UIBarButtonItem( + title: "Toggle Focus", + style: .plain, + target: self, + action: #selector(toggleAllowsFocus) + ) + + let toggleSelectionButton = UIBarButtonItem( + title: "Toggle Selection", + style: .plain, + target: self, + action: #selector(toggleSelectionFollowsFocus) + ) + + self.navigationItem.rightBarButtonItems = [toggleFocusButton, toggleSelectionButton] + } + + @objc private func toggleAllowsFocus() { + allowsFocus.toggle() + self.reload(animated: true) + } + + @objc private func toggleSelectionFollowsFocus() { + selectionFollowsFocus.toggle() + self.reload(animated: true) + } + + override func configure(list: inout ListProperties) { + + list.appearance = .demoAppearance + list.layout = .table() + + if allowsFocus { + if selectionFollowsFocus { + list.behavior.focus = .selectionFollowsFocus(showFocusRing: true) + } else { + list.behavior.focus = .allowsFocus + } + } else { + list.behavior.focus = Behavior.FocusConfiguration.none + } + + list.content.header = DemoHeader( + title: "Keyboard Navigation Demo\nFocus: \(allowsFocus ? "ON" : "OFF") | Selection Follows: \(selectionFollowsFocus ? "ON" : "OFF")" + ) + + list.add { + Section("instructions") { + Item( + DemoItem(text: "Instructions:\n• Use Tab key to navigate between items\n• Use Arrow keys to navigate within the list\n• Press Return to select items\n• Space key works only with Full Keyboard Access enabled\n• Toggle settings with navigation buttons"), + selectionStyle: .none + ) + } header: { + DemoHeader(title: "How to Use") + } + + Section("demo-items") { + // Create a few selectable items for focus testing + for i in 1...5 { + Item( + DemoItem(text: "Focusable Item \(i)"), + selectionStyle: .selectable(), + onSelect: { _ in + print("Selected Focusable Item \(i)") + } + ) + } + } header: { + DemoHeader(title: "Focusable Items") + } + + Section("mixed-items") { + Item( + DemoItem(text: "Tappable Item (Focus + Select)"), + selectionStyle: .tappable, + onSelect: { _ in + print("Selected Tappable Item") + } + ) + + Item( + DemoItem(text: "Toggle Item (Focus + Toggle)"), + selectionStyle: .toggles(), + onSelect: { _ in + print("Toggled Toggle Item") + } + ) + + Item( + DemoItem(text: "Non-selectable Item (No Focus)"), + selectionStyle: .none + ) + + Item( + DemoItem(text: "Another Selectable Item"), + selectionStyle: .selectable(), + onSelect: { _ in + print("Selected Another Selectable Item") + } + ) + } header: { + DemoHeader(title: "Mixed Selection Styles") + } + } + } +} diff --git a/Development/Sources/Demos/DemosRootViewController.swift b/Development/Sources/Demos/DemosRootViewController.swift index ea28664cd..272a8ef73 100644 --- a/Development/Sources/Demos/DemosRootViewController.swift +++ b/Development/Sources/Demos/DemosRootViewController.swift @@ -146,6 +146,14 @@ public final class DemosRootViewController : ListViewController } ) + Item( + DemoItem(text: "Keyboard Navigation Focus Demo"), + selectionStyle: .selectable(), + onSelect : { _ in + self?.push(KeyboardNavigationDemoViewController()) + } + ) + Item( DemoItem(text: "Reordering"), selectionStyle: .selectable(), diff --git a/ListableUI/Sources/Behavior.swift b/ListableUI/Sources/Behavior.swift index af6bbd5c6..024159067 100644 --- a/ListableUI/Sources/Behavior.swift +++ b/ListableUI/Sources/Behavior.swift @@ -48,6 +48,16 @@ public struct Behavior : Equatable /// Applicable when the `layoutDirection` is `vertical`. The gravity determines /// how inserting new elements or changing the `contentInset` affects the scroll position. public var verticalLayoutGravity : VerticalLayoutGravity + + /// Configuration for keyboard focus behavior in the list view. + /// + /// - `.none`: No focus support - keyboard navigation is disabled + /// - `.allowsFocus`: Basic focus support with keyboard navigation, but selection doesn't follow focus + /// - `.selectionFollowsFocus`: Focus support where selection automatically follows focus changes + /// + /// When focus is enabled, items that support selection can receive focus for keyboard navigation. + /// The focus ring will be applied to focused items automatically. + public var focus: FocusConfiguration /// Creates a new `Behavior` based on the provided parameters. public init( @@ -61,7 +71,8 @@ public struct Behavior : Equatable delaysContentTouches : Bool = true, pageScrollingBehavior : PageScrollingBehavior = .none, decelerationRate : DecelerationRate = .normal, - verticalLayoutGravity : VerticalLayoutGravity = .top + verticalLayoutGravity : VerticalLayoutGravity = .top, + focus: FocusConfiguration = .none ) { self.isScrollEnabled = isScrollEnabled self.keyboardDismissMode = keyboardDismissMode @@ -78,6 +89,7 @@ public struct Behavior : Equatable self.decelerationRate = decelerationRate self.verticalLayoutGravity = verticalLayoutGravity + self.focus = focus } } @@ -195,6 +207,50 @@ extension Behavior /// When a new element is inserted, the scroll distance from the bottom is unchanged. case bottom } + + /// Configuration for keyboard focus behavior in the list view. + public enum FocusConfiguration: Equatable { + /// No focus support - keyboard navigation is disabled. + case none + + /// Basic focus support - allows keyboard navigation but selection doesn't follow focus. + /// The focus ring is always shown to provide visual feedback for navigation. + case allowsFocus + + /// Focus with selection following - keyboard navigation enabled and selection follows focus. + /// - Parameter showFocusRing: Whether to show the focus ring around focused items. + case selectionFollowsFocus(showFocusRing: Bool = true) + + /// Whether items can receive focus for keyboard navigation. + public var allowsFocus: Bool { + switch self { + case .none: return false + case .allowsFocus, .selectionFollowsFocus: return true + } + } + + /// Whether selection automatically follows focus changes. + public var selectionFollowsFocus: Bool { + switch self { + case .none, .allowsFocus: return false + case .selectionFollowsFocus: return true + } + } + + /// Whether to show the focus ring on focused items. + public var showFocusRing: Bool { + switch self { + case .none: + return false + case .allowsFocus: + return true // Always show focus ring for allowsFocus to provide visual feedback + case .selectionFollowsFocus(let showFocusRing): + return showFocusRing + } + } + + + } } extension UICollectionView.DecelerationRate diff --git a/ListableUI/Sources/ListView/ListView.Delegate.swift b/ListableUI/Sources/ListView/ListView.Delegate.swift index c666231c2..e0ad9c92c 100644 --- a/ListableUI/Sources/ListView/ListView.Delegate.swift +++ b/ListableUI/Sources/ListView/ListView.Delegate.swift @@ -147,7 +147,9 @@ extension ListView item.willDisplay(cell: cell, in: collectionView, for: indexPath) self.displayedItems[ObjectIdentifier(cell)] = item - + + cell.focusEffect = view.behavior.focus.allowsFocus && view.behavior.focus.showFocusRing ? UIFocusHaloEffect() : nil + UIView.performWithoutAnimation { /// Force a layout of the cell before it is displayed, so that any implicit animations /// are avoided. This ensures that cases like toggling a switch on and off are @@ -214,6 +216,18 @@ extension ListView headerFooter.collectionViewDidEndDisplay(of: container) } + // MARK: UICollectionViewDelegate - Focus Support + + func collectionView(_ collectionView: UICollectionView, canFocusItemAt indexPath: IndexPath) -> Bool { + guard view.behavior.focus.allowsFocus else { return false } + let item = self.presentationState.item(at: indexPath) + return item.anyModel.selectionStyle.isSelectable + } + + func collectionView(_ collectionView: UICollectionView, selectionFollowsFocusForItemAt indexPath: IndexPath) -> Bool { + return view.behavior.focus.selectionFollowsFocus + } + func collectionView( _ collectionView: UICollectionView, targetIndexPathForMoveFromItemAt from: IndexPath, diff --git a/ListableUI/Sources/ListView/ListView.swift b/ListableUI/Sources/ListView/ListView.swift index 9ddc05669..2a91f7366 100644 --- a/ListableUI/Sources/ListView/ListView.swift +++ b/ListableUI/Sources/ListView/ListView.swift @@ -302,6 +302,19 @@ public final class ListView : UIView self.collectionView.decelerationRate = newDecelerationRate } + // Apply focus behavior + switch self.behavior.focus { + case .none: + self.collectionView.allowsFocus = false + self.collectionView.selectionFollowsFocus = false + case .allowsFocus: + self.collectionView.allowsFocus = true + self.collectionView.selectionFollowsFocus = false + case .selectionFollowsFocus: + self.collectionView.allowsFocus = true + self.collectionView.selectionFollowsFocus = true + } + self.updateCollectionViewWithCurrentLayoutProperties() self.updateCollectionViewSelectionMode() diff --git a/ListableUI/Tests/BehaviorTests.swift b/ListableUI/Tests/BehaviorTests.swift index adafd91ad..2189562c5 100644 --- a/ListableUI/Tests/BehaviorTests.swift +++ b/ListableUI/Tests/BehaviorTests.swift @@ -22,6 +22,7 @@ class BehaviorTests: XCTestCase XCTAssertEqual(behavior.selectionMode, .single) XCTAssertEqual(behavior.underflow, Behavior.Underflow()) + XCTAssertEqual(behavior.focus, .none) let listView = ListView() @@ -30,6 +31,36 @@ class BehaviorTests: XCTestCase XCTAssertEqual(behavior.delaysContentTouches, listView.collectionView.delaysContentTouches) XCTAssertEqual(.init(behaviorValue: behavior.decelerationRate), listView.collectionView.decelerationRate) } + + func test_init_with_focus() + { + let behavior = Behavior(focus: .allowsFocus) + XCTAssertEqual(behavior.focus, .allowsFocus) + } + + func test_focus_configuration() + { + self.testcase("none") { + let config = Behavior.FocusConfiguration.none + XCTAssertFalse(config.allowsFocus) + XCTAssertFalse(config.selectionFollowsFocus) + XCTAssertFalse(config.showFocusRing) + } + + self.testcase("allowsFocus") { + let config = Behavior.FocusConfiguration.allowsFocus + XCTAssertTrue(config.allowsFocus) + XCTAssertFalse(config.selectionFollowsFocus) + XCTAssertTrue(config.showFocusRing) + } + + self.testcase("selectionFollowsFocus") { + let config = Behavior.FocusConfiguration.selectionFollowsFocus(showFocusRing: false) + XCTAssertTrue(config.allowsFocus) + XCTAssertTrue(config.selectionFollowsFocus) + XCTAssertFalse(config.showFocusRing) + } + } }