diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index c92bef1acc..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,7 +357,7 @@ 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 var body: some View { @@ -361,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) @@ -385,8 +427,10 @@ 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 + railVisibility.containerWidthChanged(to: size.width) + guard let newValue = window?.isFullScreen else { return } if newValue != isFullScreen { @@ -399,7 +443,9 @@ 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 { + railVisibility.splitVisibilityChanged(to: selectedTabSplitColumnVisibility) + } } }