Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
)
Comment on lines +102 to +108
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that after toggling this item, the focus selection is reset the next time I use the arrow keys for navigation. If we wanted the focused item to be preserved, is this behavior that needs to be configured by the client, or could that be something internal to our UICollectionView implementation within Listable?

(I did not have full keyboard access enabled during this flow.)

ScreenRecording_10-27-2025.10-16-24_1.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is interesting! It's hard to tell from the documentation but I wonder if this is expected UIKit behavior. When the toggle item deselects, UICollectionView loses the current focus anchor and re-evaluates from the beginning of the focus group. I think one potential solution is leveraging focusgroupidentifier and focusgrouppriority and updating a cell's focusgrouppriority if it's deselected.

Curious if you're ok with keeping the current changes for now and we could add focus preservation logic for toggles in a follow-up? I can create a ticket to track it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it! Increasing the priority when deselected sounds like a good idea to try out. Interestingly, when using full keyboard access, the toggled item stays focused between toggle states (video below). So this issue seems to be isolated to standard focus and will impact a smaller subset of users. I'm on board with merging if you'd like to move toggle focus preservation to a followup ticket!

ScreenRecording_10-31-2025.14-11-25_1.mov

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johnnewman-square Would you be able to merge on your end? I don't have the option to do so.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure thing! I'll kick off one more set of test actions and merge.


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")
}
}
}
}
8 changes: 8 additions & 0 deletions Development/Sources/Demos/DemosRootViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
58 changes: 57 additions & 1 deletion ListableUI/Sources/Behavior.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -78,6 +89,7 @@ public struct Behavior : Equatable
self.decelerationRate = decelerationRate

self.verticalLayoutGravity = verticalLayoutGravity
self.focus = focus
}
}

Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion ListableUI/Sources/ListView/ListView.Delegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions ListableUI/Sources/ListView/ListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
31 changes: 31 additions & 0 deletions ListableUI/Tests/BehaviorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class BehaviorTests: XCTestCase
XCTAssertEqual(behavior.selectionMode, .single)

XCTAssertEqual(behavior.underflow, Behavior.Underflow())
XCTAssertEqual(behavior.focus, .none)

let listView = ListView()

Expand All @@ -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)
}
}
}


Expand Down
Loading