Create a NavigationTabCoordinator to manage a TabView.

This commit is contained in:
Doug
2025-07-29 14:54:20 +01:00
committed by Doug
parent 81b733fb6a
commit 26bc46e6ff
2 changed files with 110 additions and 6 deletions

View File

@@ -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<PresentationDetent> = []
// 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)
}

View File

@@ -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<CompoundIcons, Image>
let selectedIcon: KeyPath<CompoundIcons, Image>
}
fileprivate struct TabModule: Identifiable {
let module: NavigationModule
let title: String
let icon: KeyPath<CompoundIcons, Image>
let selectedIcon: KeyPath<CompoundIcons, Image>
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)
}
}
}
}