diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index b7bef22b8..a22f54229 100644 --- a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -100,7 +100,16 @@ import SwiftUI } /// 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 diff --git a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift index 0da890f78..5e9ad6e6b 100644 --- a/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/UserSessionFlowCoordinator.swift @@ -229,6 +229,9 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol { switch action { case .presentRoom(let roomID): 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) diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift index e663ed8d1..be5311da4 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenCoordinator.swift @@ -15,6 +15,7 @@ struct SearchScreenCoordinatorParameters { enum SearchScreenCoordinatorAction { case presentRoom(roomID: String) + case cancel } final class SearchScreenCoordinator: CoordinatorProtocol { @@ -38,6 +39,8 @@ final class SearchScreenCoordinator: CoordinatorProtocol { switch action { case .presentRoom(let roomID): actionsSubject.send(.presentRoom(roomID: roomID)) + case .cancel: + actionsSubject.send(.cancel) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift index d8f423eb6..63a27d0f2 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift @@ -9,6 +9,7 @@ import Foundation enum SearchScreenViewModelAction { case presentRoom(roomID: String) + case cancel } struct SearchScreenViewState: BindableState { @@ -29,6 +30,7 @@ enum SearchScreenViewAction { case selectRoom(roomID: String) case reachedTop case reachedBottom + case cancel } struct SearchScreenRoom: Identifiable, Equatable { diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index 05a7baf2d..52d219e86 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -65,6 +65,8 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro updateVisibleRange(edge: .top) case .reachedBottom: updateVisibleRange(edge: .bottom) + case .cancel: + actionsSubject.send(.cancel) } } diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index 8273ba77b..9044c66d5 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -47,7 +47,8 @@ struct SearchScreen: View { } } .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 // and the selection resets on every switch. .onAppear { @@ -189,12 +190,12 @@ private struct SearchScreenRoomCellButtonStyle: ButtonStyle { } 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 /// 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. - 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 guard let textField = navigationController.navigationBar.topItem?.searchController?.searchBar.searchTextField else { return @@ -210,6 +211,8 @@ private extension View { moveUp() case .keyboardDownArrow: moveDown() + case .keyboardEscape: + cancel() default: return false } diff --git a/UnitTests/Sources/SearchScreenViewModelTests.swift b/UnitTests/Sources/SearchScreenViewModelTests.swift index 52c7e76f1..a862b9254 100644 --- a/UnitTests/Sources/SearchScreenViewModelTests.swift +++ b/UnitTests/Sources/SearchScreenViewModelTests.swift @@ -33,6 +33,8 @@ struct SearchScreenViewModelTests { switch action { case .presentRoom(let roomID): roomID == "2" + case .cancel: + false } } @@ -40,4 +42,20 @@ struct SearchScreenViewModelTests { 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() + } }