Fix the tab rail flickering on narrow iPad windows (#6107)

* Fix the tab rail flickering on narrow iPad windows

The rail is laid out beside the split view, so hiding it when the split
collapsed handed that width back to the split, which then had room for its
sidebar again, which showed the rail, which collapsed the split. Any window
whose width sat within a rail of the split's collapse threshold flickered
forever, an iPad mini in fullscreen among them.

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 can only be
told apart by when they arrive: the rebound lands in the next layout pass. A
collapse that soon after showing the rail is therefore taken as proof that the
rail and the sidebar don't fit at this width, and the rail is left hidden until
the window is resized. Later collapses are treated as deliberate and still hide
the rail as before.

The container width, the width the rail and the sidebar were shown not to fit
at, and the moment the rail was last shown are one piece of state with
invariants between them rather than three independent values, so keep them and
the decision they feed together in TabRailVisibility.
This commit is contained in:
Stefan Ceriu
2026-09-04 17:46:03 +03:00
committed by GitHub
parent b4395d948f
commit f224c104e0
@@ -307,6 +307,48 @@ 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
@@ -315,7 +357,7 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
@State private var standardAppearance = UITabBarAppearance()
@State private var window: UIWindow?
@State private var isFullScreen = true
@State private var isRailVisible = true
@State private var railVisibility = TabRailVisibility()
@State private var railBackgroundColor: Color = .compound.bgCanvasDefault
var body: some View {
@@ -361,7 +403,7 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
var tabRailLayout: some View {
HStack(spacing: 0) {
if isRailVisible {
if railVisibility.isVisible {
TabRailView(navigationTabCoordinator: navigationTabCoordinator, isFullScreen: isFullScreen)
.background(railBackgroundColor.ignoresSafeArea())
.animation(.easeInOut(duration: 0.4).disabledDuringTests(), value: railBackgroundColor)
@@ -385,8 +427,10 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
}
}
.onGeometryChange(for: CGSize.self) { geometry in
geometry.size // We don't need the size, but it's a signal to re-compute.
} action: { _ in
geometry.size
} action: { size in
railVisibility.containerWidthChanged(to: size.width)
guard let newValue = window?.isFullScreen else { return }
if newValue != isFullScreen {
@@ -399,7 +443,9 @@ private struct NavigationTabCoordinatorView<Tag: Hashable>: View {
// 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 { isRailVisible = selectedTabSplitColumnVisibility != .detailOnly }
DispatchQueue.main.async {
railVisibility.splitVisibilityChanged(to: selectedTabSplitColumnVisibility)
}
}
}