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) .environment(\.isInSidebar, true)
// The tab rail's background tracks the detail module, so exclude the sidebar's background value. // The tab rail's background tracks the detail module, so exclude the sidebar's background value.
.transformPreference(CompoundBackgroundPreferenceKey.self) { $0 = nil } .transformPreference(CompoundBackgroundPreferenceKey.self) { $0 = nil }
.overlay(alignment: .leading) { sidebarDivider } .overlay(alignment: .trailing) { NavigationSidebarDivider() }
.overlay(alignment: .trailing) { sidebarDivider }
} detail: { } detail: {
if let detailModule = navigationSplitCoordinator.detailModule { if let detailModule = navigationSplitCoordinator.detailModule {
detailModule.coordinator?.toPresentable() detailModule.coordinator?.toPresentable()
@@ -416,9 +415,11 @@ private struct NavigationSplitCoordinatorView: View {
.animation(.elementDefault, value: navigationSplitCoordinator.sidebarModule) .animation(.elementDefault, value: navigationSplitCoordinator.sidebarModule)
.animation(.noAnimation, value: navigationSplitCoordinator.detailModule) // Don't crossfade the detail transition on iPad. .animation(.noAnimation, value: navigationSplitCoordinator.detailModule) // Don't crossfade the detail transition on iPad.
} }
}
@ViewBuilder /// The vertical divider that separates the sidebar from its neighbours on iOS 27, where the sidebar is no longer inset.
var sidebarDivider: some View { struct NavigationSidebarDivider: View {
var body: some View {
if #available(iOS 27, *) { if #available(iOS 27, *) {
HStack(spacing: 0) { // The stack is necessary for the divider's orientation. HStack(spacing: 0) { // The stack is necessary for the divider's orientation.
Divider() Divider()
@@ -307,48 +307,6 @@ extension EnvironmentValues {
@Entry var tabViewHorizontalSizeClass: UserInterfaceSizeClass? @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 { private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -357,7 +315,6 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
@State private var standardAppearance = UITabBarAppearance() @State private var standardAppearance = UITabBarAppearance()
@State private var window: UIWindow? @State private var window: UIWindow?
@State private var isFullScreen = true @State private var isFullScreen = true
@State private var railVisibility = TabRailVisibility()
@State private var railBackgroundColor: Color = .compound.bgCanvasDefault @State private var railBackgroundColor: Color = .compound.bgCanvasDefault
var body: some View { 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 { 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) { HStack(spacing: 0) {
if railVisibility.isVisible { TabRailView(navigationTabCoordinator: navigationTabCoordinator, isFullScreen: isFullScreen)
TabRailView(navigationTabCoordinator: navigationTabCoordinator, isFullScreen: isFullScreen) .background(railBackgroundColor.ignoresSafeArea())
.background(railBackgroundColor.ignoresSafeArea()) .animation(.easeInOut(duration: 0.4).disabledDuringTests(), value: railBackgroundColor)
.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 }) { if let module = navigationTabCoordinator.tabModules.first(where: { $0.details.tag == navigationTabCoordinator.selectedTab }) {
module.coordinator?.toPresentable() module.coordinator?.toPresentable()
@@ -428,26 +381,14 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
} }
} }
.onGeometryChange(for: CGSize.self) { geometry in .onGeometryChange(for: CGSize.self) { geometry in
geometry.size geometry.size // We don't need the size, but it's a signal to re-compute.
} action: { size in } action: { _ in
railVisibility.containerWidthChanged(to: size.width)
guard let newValue = window?.isFullScreen else { return } guard let newValue = window?.isFullScreen else { return }
if newValue != isFullScreen { if newValue != isFullScreen {
isFullScreen = newValue 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 { var tabViewLayout: some View {
@@ -489,14 +430,6 @@ struct TabRailView<Tag: Hashable>: View {
isFullScreen || ProcessInfo.processInfo.isiOSAppOnMac ? 0 : 48 isFullScreen || ProcessInfo.processInfo.isiOSAppOnMac ? 0 : 48
} }
var trailingPadding: CGFloat {
if #available(iOS 27, *) {
8
} else {
0
}
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 16) { VStack(spacing: 16) {
@@ -516,8 +449,7 @@ struct TabRailView<Tag: Hashable>: View {
.badge(10) // TODO: Check if this works. .badge(10) // TODO: Check if this works.
} }
} }
.padding(.leading, 8) .padding(.horizontal, 8)
.padding(.trailing, trailingPadding)
.padding(.top, topPadding) .padding(.top, topPadding)
.padding(.bottom) .padding(.bottom)
} }