Add keyboard arrow key navigation

This commit is contained in:
Maurice Parker 2019-09-04 21:06:29 -05:00
parent da8250ac5a
commit 7a452e2a3c
5 changed files with 177 additions and 15 deletions

View File

@ -26,6 +26,15 @@ class DetailViewController: UIViewController {
weak var coordinator: SceneCoordinator!
lazy var keyboardManager = KeyboardManager(type: .detail, coordinator: coordinator)
override var keyCommands: [UIKeyCommand]? {
return keyboardManager.keyCommands
}
override var canBecomeFirstResponder: Bool {
return true
}
deinit {
webView.removeFromSuperview()
DetailViewControllerWebViewProvider.shared.enqueueWebView(webView)
@ -164,12 +173,20 @@ class DetailViewController: UIViewController {
present(activityViewController, animated: true)
}
// MARK: Keyboard Shortcuts
@objc func navigateToTimeline(_ sender: Any?) {
coordinator.navigateToTimeline()
}
// MARK: API
func updateArticleSelection() {
updateUI()
reloadHTML()
}
func focus() {
webView.becomeFirstResponder()
}
}

View File

@ -39,21 +39,31 @@ private extension KeyboardManager {
let specificCommands = specificEntries.compactMap { createKeyCommand(keyEntry: $0) }
globalCommands.append(contentsOf: specificCommands)
if type == .sidebar {
globalCommands.append(contentsOf: sidebarAuxilaryKeyCommands())
}
keyCommands = globalCommands
}
func createKeyCommand(keyEntry: [String: Any]) -> UIKeyCommand? {
guard let input = createKeyCommandInput(keyEntry: keyEntry) else { return nil }
let modifiers = createKeyModifierFlags(keyEntry: keyEntry)
let action = NSSelectorFromString(keyEntry["action"] as! String)
let action = keyEntry["action"] as! String
if let title = keyEntry["title"] as? String {
return UIKeyCommand(title: title, image: nil, action: action, input: input, modifierFlags: modifiers, propertyList: nil, alternates: [], discoverabilityTitle: nil, attributes: [], state: .on)
return createKeyCommand(title: title, action: action, input: input, modifiers: modifiers)
} else {
return UIKeyCommand(input: input, modifierFlags: modifiers, action: action)
return UIKeyCommand(input: input, modifierFlags: modifiers, action: NSSelectorFromString(action))
}
}
func createKeyCommand(title: String, action: String, input: String, modifiers: UIKeyModifierFlags) -> UIKeyCommand {
let selector = NSSelectorFromString(action)
return UIKeyCommand(title: title, image: nil, action: selector, input: input, modifierFlags: modifiers, propertyList: nil, alternates: [], discoverabilityTitle: nil, attributes: [], state: .on)
}
func createKeyCommandInput(keyEntry: [String: Any]) -> String? {
guard let key = keyEntry["key"] as? String else { return nil }
@ -106,4 +116,16 @@ private extension KeyboardManager {
return flags
}
func sidebarAuxilaryKeyCommands() -> [UIKeyCommand] {
var keys = [UIKeyCommand]()
let nextUpTitle = NSLocalizedString("Select Next Up", comment: "Select Next Up")
keys.append(createKeyCommand(title: nextUpTitle, action: "selectNextUp:", input: UIKeyCommand.inputUpArrow, modifiers: []))
let nextDownTitle = NSLocalizedString("Select Next Down", comment: "Select Next Down")
keys.append(createKeyCommand(title: nextDownTitle, action: "selectNextDown:", input: UIKeyCommand.inputDownArrow, modifiers: []))
return keys
}
}

View File

@ -382,6 +382,18 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
// MARK: Keyboard shortcuts
@objc func selectNextUp(_ sender: Any?) {
coordinator.selectPrevFeed()
}
@objc func selectNextDown(_ sender: Any?) {
coordinator.selectNextFeed()
}
@objc func navigateToTimeline(_ sender: Any?) {
coordinator.navigateToTimeline()
}
@objc func openInBrowser(_ sender: Any?) {
coordinator.showBrowserForCurrentFeed()
}
@ -389,7 +401,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
// MARK: API
func updateFeedSelection() {
if let indexPath = coordinator.currentMasterIndexPath {
if let indexPath = coordinator.currentFeedIndexPath {
if tableView.indexPathForSelectedRow != indexPath {
tableView.selectRow(at: indexPath, animated: true, scrollPosition: .middle)
}
@ -439,6 +451,10 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
}
func focus() {
becomeFirstResponder()
}
}
// MARK: MasterTableViewCellDelegate

View File

@ -98,7 +98,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
}
}
// MARK Actions
// MARK: Actions
@IBAction func markAllAsRead(_ sender: Any) {
@ -125,6 +125,24 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
coordinator.selectNextUnread()
}
// MARK: Keyboard shortcuts
@objc func selectNextUp(_ sender: Any?) {
coordinator.selectPrevArticle()
}
@objc func selectNextDown(_ sender: Any?) {
coordinator.selectNextArticle()
}
@objc func navigateToSidebar(_ sender: Any?) {
coordinator.navigateToFeeds()
}
@objc func navigateToDetail(_ sender: Any?) {
coordinator.navigateToDetail()
}
// MARK: API
func reinitializeArticles() {
@ -142,6 +160,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
if tableView.indexPathForSelectedRow != indexPath {
tableView.selectRow(at: indexPath, animated: animate, scrollPosition: .middle)
}
} else {
tableView.selectRow(at: nil, animated: animate, scrollPosition: .none)
}
updateUI()
}
@ -151,6 +171,10 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
navigationItem.searchController?.searchBar.selectedScopeButtonIndex = 1
}
func focus() {
becomeFirstResponder()
}
// MARK: - Table view
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {

View File

@ -99,7 +99,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
return sections
}
private(set) var currentMasterIndexPath: IndexPath?
private(set) var currentFeedIndexPath: IndexPath?
var timelineName: String? {
return (timelineFetcher as? DisplayNameProvider)?.nameForDisplay
@ -130,6 +130,61 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
private(set) var showFeedNames = false
private(set) var showAvatars = false
var isPrevFeedAvailable: Bool {
guard let indexPath = currentFeedIndexPath else {
return false
}
return indexPath.section > 0 || indexPath.row > 0
}
var isNextFeedAvailable: Bool {
guard let indexPath = currentFeedIndexPath else {
return false
}
let nextIndexPath: IndexPath = {
if indexPath.row + 1 >= shadowTable[indexPath.section].count {
return IndexPath(row: 0, section: indexPath.section + 1)
} else {
return IndexPath(row: indexPath.row + 1, section: indexPath.section)
}
}()
return nextIndexPath.section < shadowTable.count && nextIndexPath.row < shadowTable[nextIndexPath.section].count
}
var prevFeedIndexPath: IndexPath? {
guard isPrevFeedAvailable, let indexPath = currentFeedIndexPath else {
return nil
}
let prevIndexPath: IndexPath = {
if indexPath.row - 1 < 0 {
return IndexPath(row: shadowTable[indexPath.section - 1].count - 1, section: indexPath.section - 1)
} else {
return IndexPath(row: indexPath.row - 1, section: indexPath.section)
}
}()
return prevIndexPath
}
var nextFeedIndexPath: IndexPath? {
guard isNextFeedAvailable, let indexPath = currentFeedIndexPath else {
return nil
}
let nextIndexPath: IndexPath = {
if indexPath.row + 1 >= shadowTable[indexPath.section].count {
return IndexPath(row: 0, section: indexPath.section + 1)
} else {
return IndexPath(row: indexPath.row + 1, section: indexPath.section)
}
}()
return nextIndexPath
}
var isPrevArticleAvailable: Bool {
guard let indexPath = currentArticleIndexPath else {
return false
@ -145,14 +200,14 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
}
var prevArticleIndexPath: IndexPath? {
guard let indexPath = currentArticleIndexPath else {
guard isPrevArticleAvailable, let indexPath = currentArticleIndexPath else {
return nil
}
return IndexPath(row: indexPath.row - 1, section: indexPath.section)
}
var nextArticleIndexPath: IndexPath? {
guard let indexPath = currentArticleIndexPath else {
guard isNextArticleAvailable, let indexPath = currentArticleIndexPath else {
return nil
}
return IndexPath(row: indexPath.row + 1, section: indexPath.section)
@ -372,7 +427,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
func unreadCountFor(_ node: Node) -> Int {
// The coordinator supplies the unread count for the currently selected feed node
if let indexPath = currentMasterIndexPath, let selectedNode = nodeFor(indexPath), selectedNode == node {
if let indexPath = currentFeedIndexPath, let selectedNode = nodeFor(indexPath), selectedNode == node {
return unreadCount
}
if let unreadCountProvider = node.representedObject as? UnreadCountProvider {
@ -492,7 +547,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
navControllerForTimeline().pushViewController(masterTimelineViewController!, animated: true)
}
currentMasterIndexPath = indexPath
currentFeedIndexPath = indexPath
if let ip = indexPath, let node = nodeFor(ip), let fetcher = node.representedObject as? ArticleFetcher {
timelineFetcher = fetcher
@ -505,6 +560,18 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
selectArticle(nil)
}
func selectPrevFeed() {
if let indexPath = prevFeedIndexPath {
selectFeed(indexPath)
}
}
func selectNextFeed() {
if let indexPath = nextFeedIndexPath {
selectFeed(indexPath)
}
}
func selectArticle(_ indexPath: IndexPath?, automated: Bool = true) {
currentArticleIndexPath = indexPath
activityManager.reading(currentArticle)
@ -518,6 +585,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
let systemMessageViewController = UIStoryboard.main.instantiateController(ofType: SystemMessageViewController.self)
installDetailController(systemMessageViewController)
}
masterTimelineViewController?.updateArticleSelection(animate: true)
return
}
@ -555,7 +623,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
lastSearchScope = nil
searchArticleIds = nil
if let ip = currentMasterIndexPath, let node = nodeFor(ip), let fetcher = node.representedObject as? ArticleFetcher {
if let ip = currentFeedIndexPath, let node = nodeFor(ip), let fetcher = node.representedObject as? ArticleFetcher {
timelineFetcher = fetcher
} else {
timelineFetcher = nil
@ -674,7 +742,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
markArticles(Set([article]), statusKey: .starred, flag: !article.status.starred)
}
}
func toggleStar(for indexPath: IndexPath) {
let article = articles[indexPath.row]
@ -727,7 +794,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
}
func showBrowserForCurrentFeed() {
if let ip = currentMasterIndexPath, let url = homePageURLForFeed(ip) {
if let ip = currentFeedIndexPath, let url = homePageURLForFeed(ip) {
UIApplication.shared.open(url, options: [:])
}
}
@ -746,6 +813,22 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
UIApplication.shared.open(url, options: [:])
}
func navigateToFeeds() {
masterFeedViewController?.focus()
selectArticle(nil)
}
func navigateToTimeline() {
masterTimelineViewController?.focus()
if currentArticleIndexPath == nil {
selectArticle(IndexPath(row: 0, section: 0))
}
}
func navigateToDetail() {
detailViewController?.focus()
}
}
// MARK: UISplitViewControllerDelegate
@ -941,7 +1024,7 @@ private extension SceneCoordinator {
func selectNextUnreadFeedFetcher() {
guard let indexPath = currentMasterIndexPath else {
guard let indexPath = currentFeedIndexPath else {
assertionFailure()
return
}
@ -1250,7 +1333,7 @@ private extension SceneCoordinator {
masterNavigationController.viewControllers = [masterFeedViewController]
}
if currentMasterIndexPath == nil && currentArticleIndexPath == nil {
if currentFeedIndexPath == nil && currentArticleIndexPath == nil {
let wrappedSystemMessageController = fullyWrappedSystemMesssageController(showButton: false)
rootSplitViewController.showDetailViewController(wrappedSystemMessageController, sender: self)