Files
element-x-ios/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift
T
Stefan Ceriu 27e59a81e4 Fix integration tests following the search tab introduction
Tabbar tabs now can take accessibility identifiers and the integration test uses the newly expected flow.
2026-10-14 20:14:27 +03:00

501 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
let accessibilityIdentifier: String?
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, accessibilityIdentifier: String? = nil) {
self.tag = tag
self.title = title
self.icon = icon
self.selectedIcon = selectedIcon
self.isSearch = isSearch
self.accessibilityIdentifier = accessibilityIdentifier
}
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)
.accessibilityIdentifier(module.details.accessibilityIdentifier ?? "")
}
}
.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
}
}