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:
Stefan Ceriu
2026-06-22 16:22:57 +03:00
parent a04a4c2691
commit c5653c2208
7 changed files with 44 additions and 4 deletions
@@ -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()
}
} }