Files
element-x-ios/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift
T
Stefan Ceriu 46eb23fa64 Show the unread notification count on the chats tab
Set the chats tab's badge count from the same value used for the app badge.
The tab bar already renders it through the .badge modifier but that has no
effect outside of a TabView or List, so the iPad tab rail now overlays a
small NotificationCountBadge instead.
2026-10-09 11:44:39 +03:00

498 lines
20 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)")
}
}
}
extension EnvironmentValues {
/// The horizontal size class of the ``NavigationTabCoordinatorView`` that contains this view.
/// This provides a stable value regardless of whether the ``TabRailView`` is in the layout hierarchy.
///
/// The rail consumes width and can cause a layout loop with the ``NavigationSplitCoordinatorView``
/// when the split becomes compact, hides the rail but then has enough space to become regular and so
/// attempts to show the rail etc.
@Entry var tabViewHorizontalSizeClass: UserInterfaceSizeClass?
}
private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Bindable var navigationTabCoordinator: NavigationTabCoordinator<Tag>
@State private var standardAppearance = UITabBarAppearance()
@State private var window: UIWindow?
@State private var isFullScreen = true
@State private var railBackgroundColor: Color = .compound.bgCanvasDefault
var body: some View {
tabView
.environment(\.tabViewHorizontalSizeClass, horizontalSizeClass)
.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)
}
}
@ViewBuilder
var tabView: some View {
if horizontalSizeClass == .regular, #available(iOS 26, *) {
tabRailLayout
} else {
tabViewLayout
}
}
var tabRailLayout: some View {
// The rail is always shown. Removing it hands its width back to the split view, which then has
// room for its sidebar again, which shows the rail, ad infinitum. Keeping it means the split's
// width never depends on it and the two can't drive each other.
HStack(spacing: 0) {
TabRailView(navigationTabCoordinator: navigationTabCoordinator, isFullScreen: isFullScreen)
.background(railBackgroundColor.ignoresSafeArea())
.animation(.easeInOut(duration: 0.4).disabledDuringTests(), value: railBackgroundColor)
.overlay(alignment: .trailing) { NavigationSidebarDivider() }
.zIndex(1)
if let module = navigationTabCoordinator.tabModules.first(where: { $0.details.tag == navigationTabCoordinator.selectedTab }) {
module.coordinator?.toPresentable()
.id(module.id)
}
}
// Match the rail's background to the colour of the split view's detail (or the root if there isn't a split).
.onPreferenceChange(CompoundBackgroundPreferenceKey.self) { background in
guard #unavailable(iOS 27) else { return } // The background is only necessary for iOS 26's inset sidebar.
railBackgroundColor = background?.colorValue ?? .compound.bgCanvasDefault
}
.introspect(.window, on: .supportedVersions) { window in
guard self.window !== window else { return }
DispatchQueue.main.async {
self.window = window
isFullScreen = window.isFullScreen
}
}
.onGeometryChange(for: CGSize.self) { geometry in
geometry.size // We don't need the size, but it's a signal to re-compute.
} action: { _ in
guard let newValue = window?.isFullScreen else { return }
if newValue != isFullScreen {
isFullScreen = newValue
}
}
}
var tabViewLayout: 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()
.backportTabViewSearchActivationOnSearchTabSelection()
.introspect(.tabView, on: .supportedVersions, customize: configureAppearance)
}
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
}
}
struct TabRailView<Tag: Hashable>: View {
let navigationTabCoordinator: NavigationTabCoordinator<Tag>
let isFullScreen: Bool
var topPadding: CGFloat {
// Add additional top padding on iPad when the traffic light buttons are shown.
isFullScreen || ProcessInfo.processInfo.isiOSAppOnMac ? 0 : 48
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
ForEach(navigationTabCoordinator.tabModules) { module in
let isSelected = module.details.tag == navigationTabCoordinator.selectedTab
Button {
navigationTabCoordinator.selectedTab = module.details.tag
} label: {
Label {
Text(module.details.title)
} icon: {
CompoundIcon(isSelected ? module.details.selectedIcon : module.details.icon)
}
}
.buttonStyle(TabButtonStyle(isSelected: isSelected))
.accessibilityAddTraits(isSelected ? .isSelected : [])
.overlay(alignment: .topTrailing) {
if module.details.badgeCount > 0 {
NotificationCountBadge(count: module.details.badgeCount, size: .small)
.allowsHitTesting(false)
}
}
}
}
.padding(.horizontal, 8)
.padding(.top, topPadding)
.padding(.bottom)
}
.scrollBounceBehavior(.basedOnSize)
.fixedSize(horizontal: true, vertical: false)
.accessibilityElement(children: .contain)
.accessibilityAddTraits(.isTabBar)
}
struct TabButtonStyle: ButtonStyle {
let isSelected: Bool
func makeBody(configuration: Configuration) -> some View {
configuration.label
.labelStyle(.iconOnly)
.foregroundStyle(.compound.iconPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(capsuleColor(isPressed: configuration.isPressed), in: .capsule)
.contentShape(.capsule)
}
func capsuleColor(isPressed: Bool) -> Color {
if isSelected {
.compound.bgSubtleSecondary
} else if isPressed {
.compound.bgSubtleSecondary.opacity(0.5)
} else {
.clear
}
}
}
}
private extension UIWindow {
var isFullScreen: Bool {
frame.size == windowScene?.screen.bounds.size
}
}