iOS: fix tab bar content overlap — inset scroll views directly
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) <[email protected]>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ struct HomeView: View {
|
||||
}
|
||||
.navigationTitle("Home")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.compactTabBarInset()
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.refreshable { store.refreshSessions() }
|
||||
.onChange(of: focusNewChat.wrappedValue) { _, want in
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -59,6 +59,7 @@ struct SessionsView: View {
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.refreshable { store.refreshSessions() }
|
||||
.compactTabBarInset()
|
||||
}
|
||||
}
|
||||
.navigationTitle("Sessions")
|
||||
|
||||
@@ -158,6 +158,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.compactTabBarInset()
|
||||
.sheet(isPresented: $showScanner) {
|
||||
PairingScannerView { payload in
|
||||
showScanner = false
|
||||
|
||||
Reference in New Issue
Block a user