mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 16:24:11 +00:00
27e59a81e4
Tabbar tabs now can take accessibility identifiers and the integration test uses the newly expected flow.
501 lines
20 KiB
Swift
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
|
|
}
|
|
}
|