diff --git a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift index 9c2510b98..675da5c19 100644 --- a/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift +++ b/ElementX/Sources/Application/Navigation/NavigationCoordinators.swift @@ -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() diff --git a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift index 67dae1fa5..30e00fa71 100644 --- a/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift +++ b/ElementX/Sources/Application/Navigation/NavigationTabCoordinator.swift @@ -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: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -357,7 +315,6 @@ private struct NavigationTabCoordinatorView: 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: 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: 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: 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: View { .badge(10) // TODO: Check if this works. } } - .padding(.leading, 8) - .padding(.trailing, trailingPadding) + .padding(.horizontal, 8) .padding(.top, topPadding) .padding(.bottom) }