From 26bc46e6ffd44ab97b8bf0bb185fc88aa9922dec Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 29 Jul 2025 14:54:20 +0100 Subject: [PATCH] Create a NavigationTabCoordinator to manage a TabView. --- .../Navigation/NavigationCoordinators.swift | 13 ++- .../Navigation/NavigationTabCoordinator.swift | 103 ++++++++++++++++++ 2 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift diff --git a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift index c06a31b0f..838c8251a 100644 --- a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift +++ b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift @@ -449,6 +449,7 @@ private struct NavigationSplitCoordinatorView: View { .animation(.elementDefault, value: navigationSplitCoordinator.overlayPresentationMode) .animation(.elementDefault, value: navigationSplitCoordinator.overlayModule) } + .ignoresSafeArea() // Necessary when embedded in a TabView on iPadOS otherwise there's a gap at the top (as of 18.5). } /// The NavigationStack that will be used in compact layouts @@ -512,7 +513,7 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS } } - // The stack's current root coordinator + /// The stack's current root coordinator var rootCoordinator: (any CoordinatorProtocol)? { rootModule?.coordinator } @@ -533,8 +534,8 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS var presentationDetents: Set = [] - // The currently presented sheet coordinator - // Sheets will be presented through the NavigationSplitCoordinator if provided + /// The currently presented sheet coordinator + /// Sheets will be presented through the NavigationSplitCoordinator if provided var sheetCoordinator: (any CoordinatorProtocol)? { if let navigationSplitCoordinator { return navigationSplitCoordinator.sheetCoordinator @@ -558,8 +559,8 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS } // periphery:ignore - might be useful to have - // The currently presented fullscreen cover coordinator - // Fullscreen covers will be presented through the NavigationSplitCoordinator if provided + /// The currently presented fullscreen cover coordinator + /// Fullscreen covers will be presented through the NavigationSplitCoordinator if provided var fullScreenCoverCoordinator: (any CoordinatorProtocol)? { if let navigationSplitCoordinator { return navigationSplitCoordinator.fullScreenCoverCoordinator @@ -584,7 +585,7 @@ class NavigationStackCoordinator: ObservableObject, CoordinatorProtocol, CustomS } } - // The current navigation stack. Excludes the rootCoordinator + /// The current navigation stack. Excludes the rootCoordinator var stackCoordinators: [any CoordinatorProtocol] { stackModules.compactMap(\.coordinator) } diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift new file mode 100644 index 000000000..4b46d3e7e --- /dev/null +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -0,0 +1,103 @@ +// +// 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 title: String + let icon: KeyPath + let selectedIcon: KeyPath + } + + fileprivate struct TabModule: Identifiable { + let module: NavigationModule + let title: String + let icon: KeyPath + let selectedIcon: KeyPath + + 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), title: $0.title, icon: $0.icon, selectedIcon: $0.selectedIcon) } + } + } + + // MARK: - CoordinatorProtocol + + 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 { + @Bindable var navigationTabCoordinator: NavigationTabCoordinator + @State private var selectedTab: ObjectIdentifier? + + var body: some View { + TabView(selection: $selectedTab) { + ForEach(navigationTabCoordinator.tabModules) { module in + module.coordinator?.toPresentable() + .tabItem { + Label { + Text(module.title) + } icon: { + CompoundIcon(module.id == selectedTab ? module.selectedIcon : module.icon) + } + } + .tag(module.id) + .id(module.id) + } + } + } +}