* Update copyright holding and dates * compound IDE Macros updated * update copyright * update copyrights done * update templates and README
346 lines
14 KiB
Swift
346 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)?
|
|
}
|
|
|
|
@MainActor
|
|
@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>
|
|
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<CompoundIcons, Image>, selectedIcon: KeyPath<CompoundIcons, Image>) {
|
|
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<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
|
|
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
|
|
}
|
|
}
|