Add a custom TabRail on iPad instead of the top tab bar.

Warning: There's currently an observation tracking feedback loop 😕
This commit is contained in:
Doug
2026-03-27 13:35:25 +00:00
committed by Doug
parent bbc995e2c9
commit 7c245acaaf
2 changed files with 153 additions and 26 deletions
@@ -7,6 +7,7 @@
//
import Combine
import Compound
import SwiftUI
/// Class responsible for displaying 2 coordinators side by side and collapsing them
@@ -154,6 +155,9 @@ import SwiftUI
compactLayoutStackModules.compactMap(\.coordinator)
}
/// Tracks the current column visibility of the split view. Only meaningful in regular (non-compact) layouts.
var columnVisibility = NavigationSplitViewVisibility.all
/// Default NavigationSplitCoordinator initialiser
/// - Parameter placeholderCoordinator: coordinator to use if no siderbar or detail is set
init(placeholderCoordinator: CoordinatorProtocol) {
@@ -331,8 +335,6 @@ extension EnvironmentValues {
}
private struct NavigationSplitCoordinatorView: View {
@State private var columnVisibility = NavigationSplitViewVisibility.all
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.scenePhase) private var scenePhase
@@ -358,13 +360,13 @@ private struct NavigationSplitCoordinatorView: View {
module.coordinator?.toPresentable()
.id(module.id)
}
.onChange(of: columnVisibility) { oldValue, _ in
.onChange(of: navigationSplitCoordinator.columnVisibility) { oldValue, _ in
// Preserve the current column visibility when backgrounding the app
if scenePhase == .background {
columnVisibility = oldValue
navigationSplitCoordinator.columnVisibility = oldValue
}
}
.ignoresSafeArea() // Necessary when embedded in a TabView on iPadOS otherwise there's a gap at the top (as of 18.5).
.ignoresSafeArea(edges: Compound.supportsGlass ? [] : .all) // When embedded in a TabView on iPadOS 18 there's a gap at the top.
}
/// The NavigationStack that will be used in compact layouts
@@ -382,7 +384,7 @@ private struct NavigationSplitCoordinatorView: View {
/// The NavigationSplitView that will be used in non-compact layouts
var navigationSplitView: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
NavigationSplitView(columnVisibility: $navigationSplitCoordinator.columnVisibility) {
if let sidebarModule = navigationSplitCoordinator.sidebarModule {
sidebarModule.coordinator?.toPresentable()
.environment(\.isInSidebar, true)
@@ -303,8 +303,84 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
@Bindable var navigationTabCoordinator: NavigationTabCoordinator<Tag>
@State private var standardAppearance = UITabBarAppearance()
@State private var window: UIWindow?
@State private var isFullScreen = true
var body: some View {
tabView
.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
}
}
/// The column visibility of the split coordinator for the currently selected tab, if any.
private var selectedTabSplitColumnVisibility: NavigationSplitViewVisibility {
let selectedTabDetails = navigationTabCoordinator.tabModules
.first { $0.details.tag == navigationTabCoordinator.selectedTab }?.details
return selectedTabDetails?.navigationSplitCoordinator?.columnVisibility ?? .all
}
private var isRailVisible: Bool {
selectedTabSplitColumnVisibility != .detailOnly
}
var tabRailLayout: some View {
HStack(spacing: 0) {
if isRailVisible {
TabRailView(navigationTabCoordinator: navigationTabCoordinator, isFullScreen: isFullScreen)
}
if let module = navigationTabCoordinator.tabModules.first(where: { $0.details.tag == navigationTabCoordinator.selectedTab }) {
module.coordinator?.toPresentable()
.id(module.id)
}
}
.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) {
@@ -323,26 +399,6 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
}
.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) {
@@ -353,3 +409,72 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
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 : [])
.badge(10) // TODO: Check if this works.
}
}
.padding(.leading, 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
}
}