mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 01:20:12 +00:00
Allow returning from global search with the escape key
cmd+k jumps to the search tab but left users with no way back. Pressing escape in the search field now returns to the tab they came from (never into search itself). Escape is handled at the search field's pressesBegan, alongside the existing arrow-key navigation, since the focused UIKit field swallows SwiftUI keyboard shortcuts.
This commit is contained in:
@@ -100,7 +100,16 @@ import SwiftUI
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The currently selected tab's tag.
|
/// The currently selected tab's tag.
|
||||||
var selectedTab: Tag?
|
var selectedTab: Tag? {
|
||||||
|
didSet {
|
||||||
|
if oldValue != selectedTab {
|
||||||
|
previousTab = oldValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tab that was selected before the current one, used to return to it (e.g. cancelling search).
|
||||||
|
private(set) var previousTab: Tag?
|
||||||
|
|
||||||
// MARK: Sheets
|
// MARK: Sheets
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
|||||||
switch action {
|
switch action {
|
||||||
case .presentRoom(let roomID):
|
case .presentRoom(let roomID):
|
||||||
handleAppRoute(.room(roomID: roomID, via: []), animated: true)
|
handleAppRoute(.room(roomID: roomID, via: []), animated: true)
|
||||||
|
case .cancel:
|
||||||
|
// Return to the tab the user came from, but never back into search.
|
||||||
|
navigationTabCoordinator.selectedTab = navigationTabCoordinator.previousTab == .search ? .chats : navigationTabCoordinator.previousTab ?? .chats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct SearchScreenCoordinatorParameters {
|
|||||||
|
|
||||||
enum SearchScreenCoordinatorAction {
|
enum SearchScreenCoordinatorAction {
|
||||||
case presentRoom(roomID: String)
|
case presentRoom(roomID: String)
|
||||||
|
case cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
final class SearchScreenCoordinator: CoordinatorProtocol {
|
final class SearchScreenCoordinator: CoordinatorProtocol {
|
||||||
@@ -38,6 +39,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol {
|
|||||||
switch action {
|
switch action {
|
||||||
case .presentRoom(let roomID):
|
case .presentRoom(let roomID):
|
||||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||||
|
case .cancel:
|
||||||
|
actionsSubject.send(.cancel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Foundation
|
|||||||
|
|
||||||
enum SearchScreenViewModelAction {
|
enum SearchScreenViewModelAction {
|
||||||
case presentRoom(roomID: String)
|
case presentRoom(roomID: String)
|
||||||
|
case cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SearchScreenViewState: BindableState {
|
struct SearchScreenViewState: BindableState {
|
||||||
@@ -29,6 +30,7 @@ enum SearchScreenViewAction {
|
|||||||
case selectRoom(roomID: String)
|
case selectRoom(roomID: String)
|
||||||
case reachedTop
|
case reachedTop
|
||||||
case reachedBottom
|
case reachedBottom
|
||||||
|
case cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SearchScreenRoom: Identifiable, Equatable {
|
struct SearchScreenRoom: Identifiable, Equatable {
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
|
|||||||
updateVisibleRange(edge: .top)
|
updateVisibleRange(edge: .top)
|
||||||
case .reachedBottom:
|
case .reachedBottom:
|
||||||
updateVisibleRange(edge: .bottom)
|
updateVisibleRange(edge: .bottom)
|
||||||
|
case .cancel:
|
||||||
|
actionsSubject.send(.cancel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ struct SearchScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.searchResultsKeyboardNavigation(moveUp: { moveSelection(backwards: true) },
|
.searchResultsKeyboardNavigation(moveUp: { moveSelection(backwards: true) },
|
||||||
moveDown: { moveSelection(backwards: false) })
|
moveDown: { moveSelection(backwards: false) },
|
||||||
|
cancel: { context.send(viewAction: .cancel) })
|
||||||
// The TabView calls onAppear each time the search tab is selected, so the field re-focuses
|
// The TabView calls onAppear each time the search tab is selected, so the field re-focuses
|
||||||
// and the selection resets on every switch.
|
// and the selection resets on every switch.
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -189,12 +190,12 @@ private struct SearchScreenRoomCellButtonStyle: ButtonStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extension View {
|
private extension View {
|
||||||
/// Forwards hardware up/down arrow presses from the system search field to the given closures.
|
/// Forwards hardware up/down arrow and escape presses from the system search field to the given closures.
|
||||||
///
|
///
|
||||||
/// The `.search` role tab owns its text field, so there's no SwiftUI hook for its key presses. We reach the
|
/// The `.search` role tab owns its text field, so there's no SwiftUI hook for its key presses. We reach the
|
||||||
/// field through the navigation stack (as ``SearchFieldStyle`` does) and swap its class for one that overrides
|
/// field through the navigation stack (as ``SearchFieldStyle`` does) and swap its class for one that overrides
|
||||||
/// `pressesBegan` — the same key interception the old global search performed on its own field.
|
/// `pressesBegan` — the same key interception the old global search performed on its own field.
|
||||||
func searchResultsKeyboardNavigation(moveUp: @escaping () -> Void, moveDown: @escaping () -> Void) -> some View {
|
func searchResultsKeyboardNavigation(moveUp: @escaping () -> Void, moveDown: @escaping () -> Void, cancel: @escaping () -> Void) -> some View {
|
||||||
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
|
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
|
||||||
guard let textField = navigationController.navigationBar.topItem?.searchController?.searchBar.searchTextField else {
|
guard let textField = navigationController.navigationBar.topItem?.searchController?.searchBar.searchTextField else {
|
||||||
return
|
return
|
||||||
@@ -210,6 +211,8 @@ private extension View {
|
|||||||
moveUp()
|
moveUp()
|
||||||
case .keyboardDownArrow:
|
case .keyboardDownArrow:
|
||||||
moveDown()
|
moveDown()
|
||||||
|
case .keyboardEscape:
|
||||||
|
cancel()
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ struct SearchScreenViewModelTests {
|
|||||||
switch action {
|
switch action {
|
||||||
case .presentRoom(let roomID):
|
case .presentRoom(let roomID):
|
||||||
roomID == "2"
|
roomID == "2"
|
||||||
|
case .cancel:
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,4 +42,20 @@ struct SearchScreenViewModelTests {
|
|||||||
|
|
||||||
try await deferred.fulfill()
|
try await deferred.fulfill()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func cancel() async throws {
|
||||||
|
let deferred = deferFulfillment(viewModel.actionsPublisher) { action in
|
||||||
|
switch action {
|
||||||
|
case .cancel:
|
||||||
|
true
|
||||||
|
default:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.send(viewAction: .cancel)
|
||||||
|
|
||||||
|
try await deferred.fulfill()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user