TabRail layout fixes (#6172)

* Attempt another TabRail layout fix by just keeping it visible all the time
* Move one of the sidebar dividers to the tab rail.
This commit is contained in:
Stefan Ceriu
2026-09-24 15:47:46 +03:00
committed by GitHub
parent 5e56bf3468
commit 12d937a5a8
2 changed files with 17 additions and 84 deletions
@@ -397,8 +397,7 @@ private struct NavigationSplitCoordinatorView: View {
.environment(\.isInSidebar, true)
// The tab rail's background tracks the detail module, so exclude the sidebar's background value.
.transformPreference(CompoundBackgroundPreferenceKey.self) { $0 = nil }
.overlay(alignment: .leading) { sidebarDivider }
.overlay(alignment: .trailing) { sidebarDivider }
.overlay(alignment: .trailing) { NavigationSidebarDivider() }
} detail: {
if let detailModule = navigationSplitCoordinator.detailModule {
detailModule.coordinator?.toPresentable()
@@ -416,9 +415,11 @@ private struct NavigationSplitCoordinatorView: View {
.animation(.elementDefault, value: navigationSplitCoordinator.sidebarModule)
.animation(.noAnimation, value: navigationSplitCoordinator.detailModule) // Don't crossfade the detail transition on iPad.
}
@ViewBuilder
var sidebarDivider: some View {
}
/// The vertical divider that separates the sidebar from its neighbours on iOS 27, where the sidebar is no longer inset.
struct NavigationSidebarDivider: View {
var body: some View {
if #available(iOS 27, *) {
HStack(spacing: 0) { // The stack is necessary for the divider's orientation.
Divider()
@@ -307,48 +307,6 @@ extension EnvironmentValues {
@Entry var tabViewHorizontalSizeClass: UserInterfaceSizeClass?
}
/// Tracks whether the tab rail should be shown alongside the split view's sidebar.
///
/// The rail is laid out beside the split view, so hiding it hands that width back to the split, which may
/// then have room for its sidebar again, which would show the rail, ad infinitum. The split reports the
/// same column visibility whether it collapsed on its own or because the sidebar was toggled, and at the
/// same width, so the two are told apart by when they arrive: the rebound lands in the next layout pass.
private struct TabRailVisibility {
/// The longest a collapse can follow the rail being shown and still be considered caused by it.
private static let reboundDuration: TimeInterval = 0.5
private(set) var isVisible = true
private var containerWidth: CGFloat = 0
/// The container width at which the rail and the sidebar have been shown not to fit together.
private var blockedWidth: CGFloat?
/// When the rail was last shown, used to spot the split view collapsing as a direct result of it.
private var shownDate = Date.distantPast
mutating func containerWidthChanged(to width: CGFloat) {
guard width != containerWidth else { return }
containerWidth = width
blockedWidth = nil // The rail and the sidebar may well fit at the new width.
}
mutating func splitVisibilityChanged(to visibility: NavigationSplitViewVisibility) {
if visibility == .detailOnly {
// A collapse this soon after showing the rail was caused by the rail's own width, so showing
// it again would only collapse the split view again, flickering forever. Note that the two
// don't fit at this width and leave the rail hidden until that changes.
if shownDate.timeIntervalSinceNow > -Self.reboundDuration {
blockedWidth = containerWidth
}
isVisible = false
} else if !isVisible, blockedWidth != containerWidth {
isVisible = true
shownDate = .now
}
}
}
private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -357,7 +315,6 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
@State private var standardAppearance = UITabBarAppearance()
@State private var window: UIWindow?
@State private var isFullScreen = true
@State private var railVisibility = TabRailVisibility()
@State private var railBackgroundColor: Color = .compound.bgCanvasDefault
var body: some View {
@@ -394,20 +351,16 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
}
}
/// 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
}
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) {
if railVisibility.isVisible {
TabRailView(navigationTabCoordinator: navigationTabCoordinator, isFullScreen: isFullScreen)
.background(railBackgroundColor.ignoresSafeArea())
.animation(.easeInOut(duration: 0.4).disabledDuringTests(), value: railBackgroundColor)
}
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()
@@ -428,26 +381,14 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
}
}
.onGeometryChange(for: CGSize.self) { geometry in
geometry.size
} action: { size in
railVisibility.containerWidthChanged(to: size.width)
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
}
}
.onChange(of: selectedTabSplitColumnVisibility, initial: true) {
// The rail consumes width, which feeds back into the split's column visibility (not the
// same as flattening everything into the single stack), creating an observation tracking
// feedback loop that locks up the app while resizing.
//
// Use a stored value (instead of a computed value) that is updated on the next run loop to break the loop.
DispatchQueue.main.async {
railVisibility.splitVisibilityChanged(to: selectedTabSplitColumnVisibility)
}
}
}
var tabViewLayout: some View {
@@ -489,14 +430,6 @@ struct TabRailView<Tag: Hashable>: View {
isFullScreen || ProcessInfo.processInfo.isiOSAppOnMac ? 0 : 48
}
var trailingPadding: CGFloat {
if #available(iOS 27, *) {
8
} else {
0
}
}
var body: some View {
ScrollView {
VStack(spacing: 16) {
@@ -516,8 +449,7 @@ struct TabRailView<Tag: Hashable>: View {
.badge(10) // TODO: Check if this works.
}
}
.padding(.leading, 8)
.padding(.trailing, trailingPadding)
.padding(.horizontal, 8)
.padding(.top, topPadding)
.padding(.bottom)
}