From cacbb951a4b518325ab91b0c475455da1a6672c9 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Fri, 4 Sep 2026 16:15:45 +0300 Subject: [PATCH 1/2] 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. --- .../Navigation/NavigationTabCoordinator.swift | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index c92bef1acc..7d9ea84fa5 100644 --- a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -317,6 +317,11 @@ private struct NavigationTabCoordinatorView: View { @State private var isFullScreen = true @State private var isRailVisible = true @State private var railBackgroundColor: Color = .compound.bgCanvasDefault + @State private var containerWidth: CGFloat = 0 + /// The container width at which the rail and the split view's sidebar have been shown not to fit together. + @State private var railBlockedWidth: CGFloat? + /// When the rail was last shown, used to spot the split view collapsing as a direct result of it. + @State private var railShownDate = Date.distantPast var body: some View { tabView @@ -385,8 +390,13 @@ private struct NavigationTabCoordinatorView: 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 + if size.width != containerWidth { + containerWidth = size.width + railBlockedWidth = nil // The rail and the sidebar may well fit at the new width. + } + guard let newValue = window?.isFullScreen else { return } if newValue != isFullScreen { @@ -399,7 +409,21 @@ private struct NavigationTabCoordinatorView: 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 { + if selectedTabSplitColumnVisibility == .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 railShownDate.timeIntervalSinceNow > -0.5 { + railBlockedWidth = containerWidth + } + + isRailVisible = false + } else if !isRailVisible, railBlockedWidth != containerWidth { + isRailVisible = true + railShownDate = .now + } + } } } From fcfacc7442164b15b9fa0b8a280af9de2977bf8e Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Fri, 4 Sep 2026 16:55:36 +0300 Subject: [PATCH 2/2] Group the tab rail's visibility state into a single type 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. --- .../Navigation/NavigationTabCoordinator.swift | 70 ++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index 7d9ea84fa5..8a4234e6d0 100644 --- a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -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: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -315,13 +357,8 @@ private struct NavigationTabCoordinatorView: 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 - @State private var containerWidth: CGFloat = 0 - /// The container width at which the rail and the split view's sidebar have been shown not to fit together. - @State private var railBlockedWidth: CGFloat? - /// When the rail was last shown, used to spot the split view collapsing as a direct result of it. - @State private var railShownDate = Date.distantPast var body: some View { tabView @@ -366,7 +403,7 @@ private struct NavigationTabCoordinatorView: 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) @@ -392,10 +429,7 @@ private struct NavigationTabCoordinatorView: View { .onGeometryChange(for: CGSize.self) { geometry in geometry.size } action: { size in - if size.width != containerWidth { - containerWidth = size.width - railBlockedWidth = nil // The rail and the sidebar may well fit at the new width. - } + railVisibility.containerWidthChanged(to: size.width) guard let newValue = window?.isFullScreen else { return } @@ -410,19 +444,7 @@ private struct NavigationTabCoordinatorView: View { // // Use a stored value (instead of a computed value) that is updated on the next run loop to break the loop. DispatchQueue.main.async { - if selectedTabSplitColumnVisibility == .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 railShownDate.timeIntervalSinceNow > -0.5 { - railBlockedWidth = containerWidth - } - - isRailVisible = false - } else if !isRailVisible, railBlockedWidth != containerWidth { - isRailVisible = true - railShownDate = .now - } + railVisibility.splitVisibilityChanged(to: selectedTabSplitColumnVisibility) } } }