Add keyboard arrow key navigation
This commit is contained in:
parent
da8250ac5a
commit
7a452e2a3c
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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? {
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue