Last active
March 30, 2023 22:01
-
-
Save douglashill/50728432881ef37e8b49f2a5917f462d to your computer and use it in GitHub Desktop.
A UITableView that allows navigation and selection using a hardware keyboard.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Douglas Hill, December 2018 | |
// Made for https://douglashill.co/reading-app/ | |
// Find the latest version of this file at https://github.com/douglashill/KeyboardKit | |
import UIKit | |
/// A table view that allows navigation and selection using a hardware keyboard. | |
/// Only supports a single section. | |
class KeyboardTableView: UITableView { | |
// These properties may be set or overridden to provide discoverability titles for key commands. | |
var selectAboveDiscoverabilityTitle: String? | |
var selectBelowDiscoverabilityTitle: String? | |
var selectTopDiscoverabilityTitle: String? | |
var selectBottomDiscoverabilityTitle: String? | |
var clearSelectionDiscoverabilityTitle: String? | |
var activateSelectionDiscoverabilityTitle: String? | |
override var canBecomeFirstResponder: Bool { | |
return true | |
} | |
override var keyCommands: [UIKeyCommand]? { | |
var commands = super.keyCommands ?? [] | |
commands.append(UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(selectAbove), maybeDiscoverabilityTitle: selectAboveDiscoverabilityTitle)) | |
commands.append(UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(selectBelow), maybeDiscoverabilityTitle: selectBelowDiscoverabilityTitle)) | |
commands.append(UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: .command, action: #selector(selectTop), maybeDiscoverabilityTitle: selectTopDiscoverabilityTitle)) | |
commands.append(UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: .command, action: #selector(selectBottom), maybeDiscoverabilityTitle: selectBottomDiscoverabilityTitle)) | |
commands.append(UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(clearSelection), maybeDiscoverabilityTitle: clearSelectionDiscoverabilityTitle)) | |
commands.append(UIKeyCommand(input: " ", modifierFlags: [], action: #selector(activateSelection))) | |
commands.append(UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(activateSelection), maybeDiscoverabilityTitle: activateSelectionDiscoverabilityTitle)) | |
return commands | |
} | |
@objc func selectAbove() { | |
if let oldSelectedIndexPath = indexPathForSelectedRow { | |
selectRowAtIndex(oldSelectedIndexPath.row - 1) | |
} else { | |
selectBottom() | |
} | |
} | |
@objc func selectBelow() { | |
if let oldSelectedIndexPath = indexPathForSelectedRow { | |
selectRowAtIndex(oldSelectedIndexPath.row + 1) | |
} else { | |
selectTop() | |
} | |
} | |
@objc func selectTop() { | |
selectRowAtIndex(0) | |
} | |
@objc func selectBottom() { | |
selectRowAtIndex(numberOfRows(inSection: 0) - 1) | |
} | |
/// Tries to select and scroll to the row at the given index in section 0. | |
/// Does not require the index to be in bounds. Does nothing if out of bounds. | |
private func selectRowAtIndex(_ rowIndex: Int) { | |
guard rowIndex >= 0 && rowIndex < numberOfRows(inSection: 0) else { | |
return | |
} | |
let indexPath = IndexPath(row: rowIndex, section: 0) | |
switch cellVisibility(atIndexPath: indexPath) { | |
case .fullyVisible: | |
selectRow(at: indexPath, animated: false, scrollPosition: .none) | |
case .notFullyVisible(let scrollPosition): | |
// Looks better and feel more responsive if the selection updates without animation. | |
selectRow(at: indexPath, animated: false, scrollPosition: .none) | |
scrollToRow(at: indexPath, at: scrollPosition, animated: true) | |
flashScrollIndicators() | |
} | |
} | |
/// Whether a row is fully visible, or if not if it’s above or below the viewport. | |
enum CellVisibility { case fullyVisible; case notFullyVisible(ScrollPosition); } | |
/// Whether the given row is fully visible, or if not if it’s above or below the viewport. | |
private func cellVisibility(atIndexPath indexPath: IndexPath) -> CellVisibility { | |
let rowRect = rectForRow(at: indexPath) | |
if bounds.inset(by: adjustedContentInset).contains(rowRect) { | |
return .fullyVisible | |
} | |
let position: ScrollPosition = rowRect.midY < bounds.midY ? .top : .bottom | |
return .notFullyVisible(position) | |
} | |
@objc func clearSelection() { | |
selectRow(at: nil, animated: false, scrollPosition: .none) | |
} | |
@objc func activateSelection() { | |
guard let indexPathForSelectedRow = indexPathForSelectedRow else { | |
return | |
} | |
delegate?.tableView?(self, didSelectRowAt: indexPathForSelectedRow) | |
} | |
} | |
private extension UIKeyCommand { | |
convenience init(input: String, modifierFlags: UIKeyModifierFlags, action: Selector, maybeDiscoverabilityTitle: String?) { | |
if let discoverabilityTitle = maybeDiscoverabilityTitle { | |
self.init(input: input, modifierFlags: modifierFlags, action: action, discoverabilityTitle: discoverabilityTitle) | |
} else { | |
self.init(input: input, modifierFlags: modifierFlags, action: action) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
It's times like this you question Apple's half implementation and why they don't have something like this as an option.
Really good work!