// // 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: CoordinatorProtocol, CustomStringConvertible { struct Tab { let coordinator: CoordinatorProtocol let details: TabDetails var dismissalCallback: (() -> Void)? } @MainActor @Observable class TabDetails { /// A unique tab that identifies the tab for selection. let tag: Tag let title: String let icon: KeyPath let selectedIcon: KeyPath var badgeCount = 0 var barVisibilityOverride: Visibility? /// 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, selectedIcon: KeyPath) { self.tag = tag self.title = title self.icon = icon self.selectedIcon = selectedIcon } func barVisibility(in horizontalSizeClass: UserInterfaceSizeClass?) -> Visibility { if #unavailable(iOS 18.0) { // There are glitches with the tab bar on iPadOS 17, so disable the tab bar until we have fixed it. .hidden } else if let barVisibilityOverride { barVisibilityOverride } else 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 } @MainActor 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? // 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 = [] /// 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: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Bindable var navigationTabCoordinator: NavigationTabCoordinator @State private var standardAppearance = UITabBarAppearance() var body: some View { TabView(selection: $navigationTabCoordinator.selectedTab) { ForEach(navigationTabCoordinator.tabModules) { module in module.coordinator?.toPresentable() .id(module.id) .tabItem { Label { Text(module.details.title) } icon: { CompoundIcon(module.details.tag == navigationTabCoordinator.selectedTab ? module.details.selectedIcon : module.details.icon) } } .tag(module.details.tag) .badge(module.details.badgeCount) .toolbar(module.details.barVisibility(in: horizontalSizeClass), for: .tabBar) } } .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 } }