From 5fb4daefa0b269fd27503d7f3df2da3588eec64b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Mon, 6 Jul 2026 04:32:02 +0000 Subject: [PATCH] =?UTF-8?q?iOS:=20fix=20tab=20bar=20content=20overlap=20?= =?UTF-8?q?=E2=80=94=20inset=20scroll=20views=20directly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom inset reserving space for the floating tab bar was applied to each tab's root (safeAreaInset on the NavigationStack), which the TabView does not forward to the pages' scroll content — so lists/scrollviews ran under the bar at the scroll bottom. Publish the inset height via an environment value and apply it (compactTabBarInset) directly to each tab's List/ScrollView/Form. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Views/AdaptiveRootView.swift | 55 ++++++++++++------- .../NucleicRemote/Views/HomeView.swift | 1 + .../NucleicRemote/Views/ProjectsView.swift | 2 + .../NucleicRemote/Views/SessionsView.swift | 1 + .../NucleicRemote/Views/SettingsView.swift | 1 + 5 files changed, 41 insertions(+), 19 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift index a2240d4..eeaab5e 100644 --- a/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift +++ b/NucleicRemote/NucleicRemote/Views/AdaptiveRootView.swift @@ -61,15 +61,16 @@ struct CompactRootView: View { var body: some View { TabView(selection: $tab) { - // The inset is applied per tab (not to the `TabView`): a bottom `safeAreaInset` on the - // container does NOT propagate into the tab pages' scroll content, so the list would run - // under the bar. On each tab's `NavigationStack` it reaches the scroll content and pushes - // it up above the bar. - HomeView().modifier(TabBarInset(height: contentInset)).tag(0) - SessionsView().modifier(TabBarInset(height: contentInset)).tag(1) - ProjectsView().modifier(TabBarInset(height: contentInset)).tag(2) - SettingsView().modifier(TabBarInset(height: contentInset)).tag(3) + HomeView().toolbar(.hidden, for: .tabBar).tag(0) + SessionsView().toolbar(.hidden, for: .tabBar).tag(1) + ProjectsView().toolbar(.hidden, for: .tabBar).tag(2) + SettingsView().toolbar(.hidden, for: .tabBar).tag(3) } + // Each tab's scroll view reads this and reserves room for the floating bar directly on + // itself (via `.compactTabBarInset()`). Neither a `safeAreaInset` nor `contentMargins` on + // the tab root reaches the pages' scroll content through the `TabView`, so it must be applied + // to the `List`/`ScrollView` itself. Zero (its default) on iPad, which has no bar. + .environment(\.compactTabBarInset, contentInset) // The bar itself lives in an always-mounted overlay and slides on `offset`, so it travels // continuously off the bottom of the screen (a fixed distance past the home-indicator curve) // instead of a transition that removes it mid-descent and makes it pop. @@ -126,18 +127,34 @@ private struct BarHeightKey: PreferenceKey { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } -/// Per-tab chrome for the compact shell: suppress the system tab bar (we draw our own) and reserve -/// `height` at the bottom of this tab's content so its scroll view lays out above the floating bar. -/// Applied to each tab rather than the `TabView`, whose container-level inset doesn't reach the tab -/// pages' scroll content. -private struct TabBarInset: ViewModifier { - let height: CGFloat +/// Height the compact shell's floating tab bar wants reserved at the bottom of a tab's scroll +/// content, published down the environment by `CompactRootView`. Zero on iPad (no bar). +private struct CompactTabBarInsetKey: EnvironmentKey { + static let defaultValue: CGFloat = 0 +} + +extension EnvironmentValues { + var compactTabBarInset: CGFloat { + get { self[CompactTabBarInsetKey.self] } + set { self[CompactTabBarInsetKey.self] = newValue } + } +} + +extension View { + /// Reserve room for the compact shell's floating tab bar at the bottom of a scroll view, so its + /// content clears the bar when scrolled to the end. Apply this directly to the `List`/`ScrollView` + /// inside a tab — a bottom `safeAreaInset` on the scroll view itself reliably insets its content, + /// unlike one on the tab root, which the `TabView` doesn't forward. A no-op at regular width + /// (iPad), where the environment inset is 0. + func compactTabBarInset() -> some View { modifier(CompactTabBarInsetReader()) } +} + +private struct CompactTabBarInsetReader: ViewModifier { + @Environment(\.compactTabBarInset) private var inset func body(content: Content) -> some View { - content - .toolbar(.hidden, for: .tabBar) - .safeAreaInset(edge: .bottom, spacing: 0) { - Color.clear.frame(height: height) - } + content.safeAreaInset(edge: .bottom, spacing: 0) { + Color.clear.frame(height: inset) + } } } diff --git a/NucleicRemote/NucleicRemote/Views/HomeView.swift b/NucleicRemote/NucleicRemote/Views/HomeView.swift index 61f1022..2ed6bd6 100644 --- a/NucleicRemote/NucleicRemote/Views/HomeView.swift +++ b/NucleicRemote/NucleicRemote/Views/HomeView.swift @@ -105,6 +105,7 @@ struct HomeView: View { } .navigationTitle("Home") .navigationBarTitleDisplayMode(.inline) + .compactTabBarInset() .scrollDismissesKeyboard(.interactively) .refreshable { store.refreshSessions() } .onChange(of: focusNewChat.wrappedValue) { _, want in diff --git a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift index 1cb4845..23f85e9 100644 --- a/NucleicRemote/NucleicRemote/Views/ProjectsView.swift +++ b/NucleicRemote/NucleicRemote/Views/ProjectsView.swift @@ -35,6 +35,7 @@ struct ProjectsView: View { } } } + .compactTabBarInset() } } .navigationTitle("Projects") @@ -71,6 +72,7 @@ struct ProjectDetailView: View { .navigationTitle(project.name) .navigationBarTitleDisplayMode(.inline) .scrollDismissesKeyboard(.interactively) + .compactTabBarInset() // A floating "+" scoped to this project — the glass composer opens pre-locked to it. Only // at regular width; on compact the shared tab-bar "+" (project picker) covers new chats. .newChatAffordance(project: project, isPresented: $showNewChat, enabled: sizeClass == .regular) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 05101a8..5fc3328 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -59,6 +59,7 @@ struct SessionsView: View { } .listStyle(.insetGrouped) .refreshable { store.refreshSessions() } + .compactTabBarInset() } } .navigationTitle("Sessions") diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index 1c68dcf..1278510 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -158,6 +158,7 @@ struct SettingsView: View { } } .navigationTitle("Settings") + .compactTabBarInset() .sheet(isPresented: $showScanner) { PairingScannerView { payload in showScanner = false