Files
element-x-ios/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift
T
Stefan Ceriu c5653c2208 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.
2026-06-22 16:54:30 +03:00

356 lines
14 KiB
Swift

//
// Copyright 2025 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
/// Class responsible for displaying an arbitrary number of coordinators within the tab bar.
@Observable class NavigationTabCoordinator<Tag: Hashable>: CoordinatorProtocol, CustomStringConvertible {
struct Tab {
let coordinator: CoordinatorProtocol
let details: TabDetails
var dismissalCallback: (() -> Void)?
}
@Observable class TabDetails {
/// A unique tab that identifies the tab for selection.
let tag: Tag
let title: String
let icon: KeyPath<CompoundIcons, Image>
let selectedIcon: KeyPath<CompoundIcons, Image>
/// When `true` the tab is given the `.search` role, which on iOS 26 detaches it
/// into the trailing search button in the tab bar.
let isSearch: Bool
var badgeCount = 0
/// Provide the tab's split coordinator in here to have the tab bar automatically hidden
/// when pushing a child into the split view's details on iPhone/compact iPad.
weak var navigationSplitCoordinator: NavigationSplitCoordinator?
init(tag: Tag, title: String, icon: KeyPath<CompoundIcons, Image>, selectedIcon: KeyPath<CompoundIcons, Image>, isSearch: Bool = false) {
self.tag = tag
self.title = title
self.icon = icon
self.selectedIcon = selectedIcon
self.isSearch = isSearch
}
func barVisibility(in horizontalSizeClass: UserInterfaceSizeClass?) -> Visibility {
if horizontalSizeClass == .compact, navigationSplitCoordinator?.detailCoordinator != nil {
// Whilst we support pushing screens on the stack in the sidebarCoordinator, in practice
// we never do that, so simply checking that the detailCoordinator exists is enough.
.hidden
} else {
.automatic
}
}
}
// MARK: Tabs
fileprivate struct TabModule: Identifiable {
let module: NavigationModule
let details: TabDetails
var id: ObjectIdentifier {
module.id
}
var coordinator: CoordinatorProtocol? {
module.coordinator
}
}
fileprivate var tabModules = [TabModule]() {
didSet {
let diffs = tabModules.map(\.module).difference(from: oldValue.map(\.module))
diffs.forEach { change in
switch change {
case .insert(_, let module, _):
logPresentationChange("Set tab", module)
module.coordinator?.start()
case .remove(_, let module, _):
logPresentationChange("Remove tab", module)
module.tearDown()
}
}
}
}
/// The current set of coordinators displayed by the tabs.
var tabCoordinators: [any CoordinatorProtocol] {
tabModules.compactMap(\.module.coordinator)
}
/// Updates the displayed tabs with the provided array.
func setTabs(_ tabs: [Tab], animated: Bool = true) {
var transaction = Transaction()
transaction.disablesAnimations = !animated
withTransaction(transaction) {
tabModules = tabs.map { TabModule(module: .init($0.coordinator, dismissalCallback: $0.dismissalCallback), details: $0.details) }
}
selectedTab = tabModules.first?.details.tag
}
/// The currently selected tab's 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
fileprivate var sheetModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove sheet", oldValue)
oldValue.tearDown()
}
if let sheetModule {
logPresentationChange("Set sheet", sheetModule)
sheetModule.coordinator?.start()
}
}
}
var presentationDetents: Set<PresentationDetent> = []
/// The currently presented sheet coordinator.
var sheetCoordinator: (any CoordinatorProtocol)? {
sheetModule?.coordinator
}
/// Present a sheet on top of the stack. If this NavigationStackCoordinator is embedded within a NavigationSplitCoordinator
/// then the presentation will be proxied to the split
/// - Parameters:
/// - coordinator: the coordinator to display
/// - animated: whether to animate the transition or not. Default is true
/// - dismissalCallback: called when the sheet has been dismissed, programatically or otherwise
func setSheetCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
sheetModule = nil
return
}
if sheetModule?.coordinator === coordinator {
fatalError("Cannot use the same coordinator more than once")
}
var transaction = Transaction()
transaction.disablesAnimations = !animated
withTransaction(transaction) {
sheetModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
}
// MARK: Full Screen Cover
fileprivate var fullScreenCoverModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove fullscreen cover", oldValue)
oldValue.tearDown()
}
if let fullScreenCoverModule {
logPresentationChange("Set fullscreen cover", fullScreenCoverModule)
fullScreenCoverModule.coordinator?.start()
}
}
}
/// The currently presented fullscreen cover coordinator
/// Fullscreen covers will be presented through the NavigationSplitCoordinator if provided
var fullScreenCoverCoordinator: (any CoordinatorProtocol)? {
fullScreenCoverModule?.coordinator
}
/// Present a fullscreen cover on top of the stack. If this NavigationStackCoordinator is embedded within a NavigationSplitCoordinator
/// then the presentation will be proxied to the split
/// - Parameters:
/// - coordinator: the coordinator to display
/// - animated: whether to animate the transition or not. Default is true
/// - dismissalCallback: called when the fullscreen cover has been dismissed, programatically or otherwise
func setFullScreenCoverCoordinator(_ coordinator: (any CoordinatorProtocol)?, animated: Bool = true, dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
fullScreenCoverModule = nil
return
}
if fullScreenCoverModule?.coordinator === coordinator {
fatalError("Cannot use the same coordinator more than once")
}
var transaction = Transaction()
transaction.disablesAnimations = !animated
withTransaction(transaction) {
fullScreenCoverModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
}
// MARK: - Overlay
fileprivate var overlayModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove overlay", oldValue)
oldValue.tearDown()
}
if let overlayModule {
logPresentationChange("Set overlay", overlayModule)
overlayModule.coordinator?.start()
}
}
}
/// The currently displayed overlay coordinator
var overlayCoordinator: (any CoordinatorProtocol)? {
overlayModule?.coordinator
}
enum OverlayPresentationMode { case fullScreen, minimized }
fileprivate var overlayPresentationMode: OverlayPresentationMode = .minimized
/// Present an overlay on top of the tab view
/// - Parameters:
/// - coordinator: the coordinator to display
/// - presentationMode: how the coordinator should be presented
/// - animated: whether the transition should be animated
/// - dismissalCallback: called when the overlay has been dismissed, programatically or otherwise
func setOverlayCoordinator(_ coordinator: (any CoordinatorProtocol)?,
presentationMode: OverlayPresentationMode = .fullScreen,
animated: Bool = true,
dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
overlayModule = nil
return
}
if overlayModule?.coordinator === coordinator {
fatalError("Cannot use the same coordinator more than once")
}
var transaction = Transaction()
transaction.disablesAnimations = !animated
withTransaction(transaction) {
overlayPresentationMode = presentationMode
overlayModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
}
/// Updates the presentation of the overlay coordinator.
/// - Parameters:
/// - mode: The type of presentation to use.
/// - animated: whether the transition should be animated
func setOverlayPresentationMode(_ mode: OverlayPresentationMode, animated: Bool = true) {
var transaction = Transaction()
transaction.disablesAnimations = !animated
withTransaction(transaction) {
overlayPresentationMode = mode
}
}
// MARK: - CoordinatorProtocol
/// No idea if this is particuarly needed for the TabView but we do this for the NavigationStackCoordinator and NavigationSplitCoordinator so it
/// doesn't seem to harm to also do it here.
func stop() {
tabModules.forEach { $0.module.tearDown() }
}
func toPresentable() -> AnyView {
AnyView(NavigationTabCoordinatorView(navigationTabCoordinator: self))
}
// MARK: - CustomStringConvertible
var description: String {
guard !tabModules.isEmpty else { return "NavigationTabCoordinator(Empty)" }
return "NavigationTabCoordinator(\(tabCoordinators)"
}
// MARK: - Private
private func logPresentationChange(_ change: String, _ module: NavigationModule) {
if let coordinator = module.coordinator {
MXLog.info("\(self) \(change): \(coordinator)")
}
}
}
private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Bindable var navigationTabCoordinator: NavigationTabCoordinator<Tag>
@State private var standardAppearance = UITabBarAppearance()
var body: some View {
TabView(selection: $navigationTabCoordinator.selectedTab) {
ForEach(navigationTabCoordinator.tabModules) { module in
Tab(value: module.details.tag, role: module.details.isSearch ? .search : nil) {
module.coordinator?.toPresentable()
.id(module.id)
.toolbar(module.details.barVisibility(in: horizontalSizeClass), for: .tabBar)
} label: {
Label {
Text(module.details.title)
} icon: {
CompoundIcon(module.details.tag == navigationTabCoordinator.selectedTab ? module.details.selectedIcon : module.details.icon)
}
}
.badge(module.details.badgeCount)
}
}
.backportTabBarMinimizeBehaviorOnScrollDown()
.introspect(.tabView, on: .supportedVersions, customize: configureAppearance)
.sheet(item: $navigationTabCoordinator.sheetModule) { module in
module.coordinator?.toPresentable()
.id(module.id)
}
.fullScreenCover(item: $navigationTabCoordinator.fullScreenCoverModule) { module in
module.coordinator?.toPresentable()
.id(module.id)
}
.accessibilityHidden(navigationTabCoordinator.overlayModule?.coordinator != nil && navigationTabCoordinator.overlayPresentationMode == .fullScreen)
.overlay {
Group {
if let coordinator = navigationTabCoordinator.overlayModule?.coordinator {
coordinator.toPresentable()
.opacity(navigationTabCoordinator.overlayPresentationMode == .minimized ? 0 : 1)
.transition(.opacity)
}
}
.animation(.elementDefault, value: navigationTabCoordinator.overlayPresentationMode)
.animation(.elementDefault, value: navigationTabCoordinator.overlayModule)
}
}
private func configureAppearance(_ tabBarController: UITabBarController) {
standardAppearance.configureWithDefaultBackground()
standardAppearance.stackedLayoutAppearance.normal.badgeBackgroundColor = .compound.iconAccentPrimary // iPhone Portrait
standardAppearance.compactInlineLayoutAppearance.normal.badgeBackgroundColor = .compound.iconAccentPrimary // iPhone Landscape
standardAppearance.inlineLayoutAppearance.normal.badgeBackgroundColor = .compound.iconAccentPrimary // iPadOS 17 (doesn't work for 18+)
tabBarController.tabBar.standardAppearance = standardAppearance
}
}