From a9659e96d56869d5f0b83adefcaac9361324324c Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Sat, 25 Jul 2026 21:03:37 -0700 Subject: [PATCH 1/2] Replace `_safeArea` environment variable with `WindowInsets` Fixes #493 --- .../SkipUI/Compose/ComposeExtensions.swift | 37 +++--- .../SkipUI/Compose/ComposeLayouts.swift | 63 ++++++----- Sources/SkipUI/SkipUI/Containers/List.swift | 3 +- .../SkipUI/SkipUI/Containers/Navigation.swift | 105 ++++++++---------- .../SkipUI/Containers/PresentationRoot.swift | 57 +++++----- .../SkipUI/SkipUI/Containers/TabView.swift | 87 ++++++++------- Sources/SkipUI/SkipUI/Containers/Table.swift | 3 +- .../Environment/EnvironmentValues.swift | 13 ++- .../SkipUI/SkipUI/Layout/GeometryProxy.swift | 23 ++-- .../SkipUI/SkipUI/Layout/GeometryReader.swift | 3 +- Sources/SkipUI/SkipUI/Layout/SafeArea.swift | 55 +-------- .../SkipUI/View/AdditionalViewModifiers.swift | 2 +- 12 files changed, 211 insertions(+), 240 deletions(-) diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift b/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift index c035bd4c..a6a389b9 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeExtensions.swift @@ -3,6 +3,7 @@ #if SKIP import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime @@ -113,19 +114,6 @@ extension Modifier { } } - /// Add padding equivalent to the given safe area. - @Composable func padding(safeArea: SafeArea) -> Modifier { - let density = LocalDensity.current - let layoutDirection = LocalLayoutDirection.current - let top = with(density) { (safeArea.safeBoundsPx.top - safeArea.presentationBoundsPx.top).toDp() } - let left = with(density) { (safeArea.safeBoundsPx.left - safeArea.presentationBoundsPx.left).toDp() } - let bottom = with(density) { (safeArea.presentationBoundsPx.bottom - safeArea.safeBoundsPx.bottom).toDp() } - let right = with(density) { (safeArea.presentationBoundsPx.right - safeArea.safeBoundsPx.right).toDp() } - let start = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Rtl ? right : left - let end = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Rtl ? left : right - return self.padding(top: top, start: start, bottom: bottom, end: end) - } - /// Invoke the given closure with the modified view's root bounds. @Composable func onGloballyPositionedInRoot(perform: (Rect) -> Void) -> Modifier { return self.onGloballyPositioned { @@ -161,6 +149,29 @@ extension Modifier { } } +/// Create fixed insets using logical leading and trailing values. +@Composable func contentWindowInsets(top: Dp = 0.dp, leading: Dp = 0.dp, bottom: Dp = 0.dp, trailing: Dp = 0.dp) -> WindowInsets { + let isRTL = LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl + let left = isRTL ? trailing : leading + let right = isRTL ? leading : trailing + return WindowInsets(left, top, right, bottom) +} + +/// Convert WindowInsets to SwiftUI EdgeInsets at the current density and layout direction. +@Composable func edgeInsets(from windowInsets: WindowInsets) -> EdgeInsets { + let values = windowInsets.asPaddingValues() + let layoutDirection = LocalLayoutDirection.current + let left = values.calculateLeftPadding(layoutDirection) + let right = values.calculateRightPadding(layoutDirection) + let isRTL = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Rtl + return EdgeInsets( + top: Double(values.calculateTopPadding().value), + leading: Double((isRTL ? right : left).value), + bottom: Double(values.calculateBottomPadding().value), + trailing: Double((isRTL ? left : right).value) + ) +} + extension PaddingValues { /// Convert padding values to edge insets in `dp` units. @Composable public func asEdgeInsets() -> EdgeInsets { diff --git a/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift b/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift index 84081fa5..02d118b2 100644 --- a/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift +++ b/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift @@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Constraints @@ -160,14 +161,6 @@ private func flexibleLayoutFloat(_ value: CGFloat?) -> Float? { /// passed to the given closure. /// - Parameter logTag: When non-empty, emits Android ``Log`` lines with tag `SkipUI.ISAL.` (e.g. filter logcat `SkipUI.ISAL.List`). @Composable func IgnoresSafeAreaLayout(expandInto: Edge.Set, checkEdges: Edge.Set = [], modifier: Modifier = Modifier, logTag: String = "", target: @Composable (IntRect, Edge.Set) -> Void) { - guard let safeArea = EnvironmentValues.shared._safeArea else { - if !logTag.isEmpty { - Log.d("SkipUI.ISAL.\(logTag)", "no SafeArea in environment; skipping expansion") - } - target(IntRect.Zero, []) - return - } - if !logTag.isEmpty { LaunchedEffect(logTag, expandInto.rawValue, checkEdges.rawValue) { Log.d("SkipUI.ISAL.\(logTag)", "init expandInto=\(expandInto) checkEdges=\(checkEdges) edgesState(initial)=\(checkEdges)") @@ -179,48 +172,54 @@ private func flexibleLayoutFloat(_ value: CGFloat?) -> Float? { // state to our output to ensure we aren't re-calling the target block when output hasn't changed let edgesState = remember { mutableStateOf(checkEdges) } let edges = edgesState.value + let insets = edgeInsets(from: EnvironmentValues.shared._contentWindowInsets) + let density = LocalDensity.current + let topInsetPx = with(density) { insets.top.dp.roundToPx() } + let bottomInsetPx = with(density) { insets.bottom.dp.roundToPx() } + let leadingInsetPx = with(density) { insets.leading.dp.roundToPx() } + let trailingInsetPx = with(density) { insets.trailing.dp.roundToPx() } var expansionTop = 0 if expandInto.contains(Edge.Set.top) && edges.contains(Edge.Set.top) { - expansionTop = Int(safeArea.safeBoundsPx.top - safeArea.presentationBoundsPx.top) + expansionTop = topInsetPx } var expansionBottom = 0 if expandInto.contains(Edge.Set.bottom) && edges.contains(Edge.Set.bottom) { - expansionBottom = Int(safeArea.presentationBoundsPx.bottom - safeArea.safeBoundsPx.bottom) + expansionBottom = bottomInsetPx } var expansionLeft = 0 var expansionRight = 0 let isRTL = LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl if isRTL { if expandInto.contains(Edge.Set.leading) && edges.contains(Edge.Set.leading) { - expansionRight = Int(safeArea.presentationBoundsPx.right - safeArea.safeBoundsPx.right) + expansionRight = leadingInsetPx } if expandInto.contains(Edge.Set.trailing) && edges.contains(Edge.Set.trailing) { - expansionLeft = Int(safeArea.safeBoundsPx.left - safeArea.presentationBoundsPx.left) + expansionLeft = trailingInsetPx } } else { if expandInto.contains(Edge.Set.leading) && edges.contains(Edge.Set.leading) { - expansionLeft = Int(safeArea.safeBoundsPx.left - safeArea.presentationBoundsPx.left) + expansionLeft = leadingInsetPx } if expandInto.contains(Edge.Set.trailing) && edges.contains(Edge.Set.trailing) { - expansionRight = Int(safeArea.presentationBoundsPx.right - safeArea.safeBoundsPx.right) + expansionRight = trailingInsetPx } } - var (safeLeft, safeTop, safeRight, safeBottom) = safeArea.safeBoundsPx - safeLeft -= expansionLeft - safeTop -= expansionTop - safeRight += expansionRight - safeBottom += expansionBottom - - let contentSafeBounds = Rect(top: safeTop, left: safeLeft, bottom: safeBottom, right: safeRight) - let contentSafeArea = SafeArea(presentation: safeArea.presentationBoundsPx, safe: contentSafeBounds, absoluteSystemBars: safeArea.absoluteSystemBarEdges) + let contentInsets = contentWindowInsets( + top: expansionTop > 0 ? 0.dp : insets.top.dp, + leading: (isRTL ? expansionRight : expansionLeft) > 0 ? 0.dp : insets.leading.dp, + bottom: expansionBottom > 0 ? 0.dp : insets.bottom.dp, + trailing: (isRTL ? expansionLeft : expansionRight) > 0 ? 0.dp : insets.trailing.dp + ) EnvironmentValues.shared.setValues { - $0.set_safeArea(contentSafeArea) + $0.set_contentWindowInsets(contentInsets) return ComposeResult.ok } in: { - Layout(modifier: modifier.onGloballyPositionedInWindow { + Layout(modifier: modifier.onGloballyPositioned { coordinates in let probeEdges = expandInto.union(checkEdges) - let newEdges = adjacentSafeAreaEdges(bounds: $0, safeArea: safeArea, isRTL: isRTL, checkEdges: probeEdges) + let bounds = coordinates.boundsInWindow() + let parentBounds = coordinates.parentLayoutCoordinates?.boundsInWindow() ?? bounds + let newEdges = adjacentSafeAreaEdges(bounds: bounds, parentBounds: parentBounds, isRTL: isRTL, checkEdges: probeEdges) if !logTag.isEmpty { let previous = edgesState.value if newEdges != previous { @@ -255,26 +254,26 @@ private func flexibleLayoutFloat(_ value: CGFloat?) -> Float? { } } -private func adjacentSafeAreaEdges(bounds: Rect, safeArea: SafeArea, isRTL: Bool, checkEdges: Edge.Set) -> Edge.Set { +private func adjacentSafeAreaEdges(bounds: Rect, parentBounds: Rect, isRTL: Bool, checkEdges: Edge.Set) -> Edge.Set { var edges: Edge.Set = [] - if checkEdges.contains(Edge.Set.top), bounds.top <= safeArea.safeBoundsPx.top + 0.1 { + if checkEdges.contains(Edge.Set.top), bounds.top <= parentBounds.top + 0.1 { edges.insert(Edge.Set.top) } - if checkEdges.contains(Edge.Set.bottom), bounds.bottom >= safeArea.safeBoundsPx.bottom - 0.1 { + if checkEdges.contains(Edge.Set.bottom), bounds.bottom >= parentBounds.bottom - 0.1 { edges.insert(Edge.Set.bottom) } if isRTL { - if checkEdges.contains(Edge.Set.leading), bounds.right >= safeArea.safeBoundsPx.right - 0.1 { + if checkEdges.contains(Edge.Set.leading), bounds.right >= parentBounds.right - 0.1 { edges.insert(Edge.Set.leading) } - if checkEdges.contains(Edge.Set.trailing), bounds.left <= safeArea.safeBoundsPx.left + 0.1 { + if checkEdges.contains(Edge.Set.trailing), bounds.left <= parentBounds.left + 0.1 { edges.insert(Edge.Set.trailing) } } else { - if checkEdges.contains(Edge.Set.leading), bounds.left <= safeArea.safeBoundsPx.left + 0.1 { + if checkEdges.contains(Edge.Set.leading), bounds.left <= parentBounds.left + 0.1 { edges.insert(Edge.Set.leading) } - if checkEdges.contains(Edge.Set.trailing), bounds.right >= safeArea.safeBoundsPx.right - 0.1 { + if checkEdges.contains(Edge.Set.trailing), bounds.right >= parentBounds.right - 0.1 { edges.insert(Edge.Set.trailing) } } diff --git a/Sources/SkipUI/SkipUI/Containers/List.swift b/Sources/SkipUI/SkipUI/Containers/List.swift index e7d029fc..b901b390 100644 --- a/Sources/SkipUI/SkipUI/Containers/List.swift +++ b/Sources/SkipUI/SkipUI/Containers/List.swift @@ -153,9 +153,8 @@ public final class List : View, Renderable { let itemContext = context.content() // When we layout, extend into safe areas that are due to system bars, not into any app chrome - let safeArea = EnvironmentValues.shared._safeArea var ignoresSafeAreaEdges: Edge.Set = [.top, .bottom] - ignoresSafeAreaEdges.formIntersection(safeArea?.absoluteSystemBarEdges ?? []) + ignoresSafeAreaEdges.formIntersection(EnvironmentValues.shared._presentationSystemBarEdges) ComposeContainer(scrollAxes: .vertical, modifier: context.modifier, fillWidth: true, fillHeight: true, then: Modifier.background(BackgroundColor(styling: styling, isItem: false))) { modifier in IgnoresSafeAreaLayout(expandInto: ignoresSafeAreaEdges, checkEdges: [.bottom], modifier: modifier, logTag: "List") { safeAreaExpansion, safeAreaEdges in var containerModifier: Modifier diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index 10f79c23..eb7ee9f1 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -3,6 +3,7 @@ #if !SKIP_BRIDGE import Foundation #if SKIP +import android.content.res.Configuration import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -77,8 +79,12 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.SoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -160,11 +166,10 @@ public struct NavigationStack : View, Renderable { // SKIP INSERT: val providedNavigator = LocalNavigator provides navigator.value CompositionLocalProvider(providedNavigator) { - let safeArea = EnvironmentValues.shared._safeArea // We have to ignore the safe area around the entire NavDisplay to prevent push/pop animation issues with the system bars. // When we layout, only extend into safe areas that are due to system bars, not into any app chrome var ignoresSafeAreaEdges: Edge.Set = [.top, .bottom] - ignoresSafeAreaEdges.formIntersection(safeArea?.absoluteSystemBarEdges ?? []) + ignoresSafeAreaEdges.formIntersection(EnvironmentValues.shared._presentationSystemBarEdges) IgnoresSafeAreaLayout(expandInto: ignoresSafeAreaEdges, checkEdges: ignoresSafeAreaEdges, logTag: "NavigationStack") { _, _ in ComposeContainer(modifier: context.modifier, fillWidth: true, fillHeight: true) { modifier in let decoratorList = listOf(rememberSaveableStateHolderNavEntryDecorator()) @@ -181,7 +186,7 @@ public struct NavigationStack : View, Renderable { let toolbarPreferencesCollector = PreferenceCollector(key: ToolbarPreferenceKey.self, state: toolbarPreferences) let toolbarContentPreferences = rememberSaveable(stateSaver: state.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: ToolbarContentPreferenceKey.self)) } let toolbarContentPreferencesCollector = PreferenceCollector(key: ToolbarContentPreferenceKey.self, state: toolbarContentPreferences) - let arguments = NavigationEntryArguments(isRoot: true, state: state, safeArea: safeArea, ignoresSafeAreaEdges: ignoresSafeAreaEdges, title: title.value.reduced, toolbarPreferences: toolbarPreferences.value.reduced) + let arguments = NavigationEntryArguments(isRoot: true, state: state, ignoresSafeAreaEdges: ignoresSafeAreaEdges, title: title.value.reduced, toolbarPreferences: toolbarPreferences.value.reduced) PreferenceValues.shared.collectPreferences([titleCollector, toolbarPreferencesCollector, toolbarContentPreferencesCollector, destinationsCollector, destinationLayoutHintsCollector]) { RenderEntry(navigator: navigator, toolbarContent: toolbarContentPreferences, arguments: arguments, context: context) { context in root.Compose(context: context) @@ -204,7 +209,7 @@ public struct NavigationStack : View, Renderable { $0.setdismiss(DismissAction(action: { navigator.value.navigateBack() })) return ComposeResult.ok } in: { - let arguments = NavigationEntryArguments(isRoot: false, state: state, safeArea: safeArea, ignoresSafeAreaEdges: ignoresSafeAreaEdges, title: title.value.reduced, toolbarPreferences: toolbarPreferences.value.reduced) + let arguments = NavigationEntryArguments(isRoot: false, state: state, ignoresSafeAreaEdges: ignoresSafeAreaEdges, title: title.value.reduced, toolbarPreferences: toolbarPreferences.value.reduced) PreferenceValues.shared.collectPreferences([titleCollector, toolbarPreferencesCollector, toolbarContentPreferencesCollector, destinationsCollector, destinationLayoutHintsCollector]) { RenderEntry(navigator: navigator, toolbarContent: toolbarContentPreferences, arguments: arguments, context: context) { context in let destinationArguments = NavigationDestinationArguments(targetValue: targetValue) @@ -261,7 +266,7 @@ public struct NavigationStack : View, Renderable { let topBarPreferences = arguments.toolbarPreferences.navigationBar let bottomBarPreferences = arguments.toolbarPreferences.bottomBar let effectiveTitleDisplayMode = navigator.value.titleDisplayMode(for: state, hasTitle: hasTitle, preference: titleDisplayPreference) - let isInlineTitleDisplayMode = useInlineTitleDisplayMode(for: effectiveTitleDisplayMode, safeArea: arguments.safeArea) + let isInlineTitleDisplayMode = useInlineTitleDisplayMode(for: effectiveTitleDisplayMode) // We would like to only process toolbar content in our topBar/bottomBar Composables, but composing // custom ToolbarContent multiple times (in order to process the placement of the items in its body @@ -315,12 +320,6 @@ public struct NavigationStack : View, Renderable { modifier = modifier.then(context.modifier) let defaultTopBarHeight = 112.dp - let topBarBottomPx = remember { - let safeAreaTopPx = arguments.safeArea?.safeBoundsPx.top ?? Float(0.0) - // Use a first-frame estimate only when a top bar will render. The measured value from - // onGloballyPositionedInWindow below is the source of truth after composition. - mutableStateOf(showTopBar ? with(density) { safeAreaTopPx + defaultTopBarHeight.toPx() } : Float(0.0)) - } let topBarHeightPx = remember { mutableStateOf(showTopBar ? with(density) { defaultTopBarHeight.toPx() } : Float(0.0)) } @@ -329,7 +328,6 @@ public struct NavigationStack : View, Renderable { // transitions where onDispose may not have fired yet. LaunchedEffect(showTopBar) { if !showTopBar { - topBarBottomPx.value = Float(0.0) topBarHeightPx.value = Float(0.0) } } @@ -344,7 +342,6 @@ public struct NavigationStack : View, Renderable { AnimatedVisibility(visible: showTopBar, modifier: Modifier.fillMaxWidth(), enter: topBarEnter, exit: topBarExit, label: "NavigationTopBar") { DisposableEffect(true) { onDispose { - topBarBottomPx.value = Float(0.0) topBarHeightPx.value = Float(0.0) } } @@ -396,7 +393,6 @@ public struct NavigationStack : View, Renderable { scrollToTop.value.reduced.action() }) .onGloballyPositionedInWindow { bounds in - topBarBottomPx.value = bounds.bottom topBarHeightPx.value = bounds.bottom - bounds.top } if !topBarHasColorScheme || isOverlapped, let topBarBackgroundForBrush { @@ -495,19 +491,16 @@ public struct NavigationStack : View, Renderable { } } - let bottomBarTopPx = remember { mutableStateOf(Float(0.0)) } let bottomBarHeightPx = remember { mutableStateOf(Float(0.0)) } let bottomBar: @Composable () -> Void = { guard bottomBarPreferences?.visibility != Visibility.hidden else { SideEffect { - bottomBarTopPx.value = Float(0.0) bottomBarHeightPx.value = Float(0.0) } return } guard bottomItems.size > 0 || bottomBarPreferences?.visibility == Visibility.visible else { SideEffect { - bottomBarTopPx.value = Float(0.0) bottomBarHeightPx.value = Float(0.0) } return @@ -554,7 +547,6 @@ public struct NavigationStack : View, Renderable { } in: { var bottomBarModifier = Modifier.zIndex(Float(1.1)) .onGloballyPositionedInWindow { bounds in - bottomBarTopPx.value = bounds.top bottomBarHeightPx.value = bounds.bottom - bounds.top } if showScrolledBackground, let bottomBarBackgroundForBrush { @@ -566,8 +558,10 @@ public struct NavigationStack : View, Renderable { let bottomPadding = with(density) { min(bottomBarHeightPx.value, Float(WindowInsets.ime.getBottom(density))).toDp() } PaddingLayout(padding: EdgeInsets(top: 0.0, leading: 0.0, bottom: Double(-bottomPadding.value), trailing: 0.0), context: context.content()) { context in let containerColor = showScrolledBackground ? bottomBarBackgroundColor : unscrolledBottomBarBackgroundColor - let windowInsets = EnvironmentValues.shared._isEdgeToEdge == true ? BottomAppBarDefaults.windowInsets : WindowInsets(bottom: 0.dp) - var options = Material3BottomAppBarOptions(modifier: context.modifier.then(bottomBarModifier), containerColor: containerColor, contentColor: MaterialTheme.colorScheme.contentColorFor(containerColor), contentPadding: PaddingValues.Absolute(left: 16.dp, right: 16.dp)) + // System bottom insets belong to TabView's NavigationBar (or PresentationRoot + // sheets). Inside those content slots BottomAppBar must not re-apply them. + let windowInsets = (EnvironmentValues.shared._isEdgeToEdge == true && EnvironmentValues.shared._presentationSystemBarEdges.contains(.bottom)) ? BottomAppBarDefaults.windowInsets : WindowInsets(bottom: 0.dp) + var options = Material3BottomAppBarOptions(modifier: context.modifier.then(bottomBarModifier).semantics { testTagsAsResourceId = true }.testTag("skip_ui_automation_bottom_app_bar"), containerColor: containerColor, contentColor: MaterialTheme.colorScheme.contentColorFor(containerColor), contentPadding: PaddingValues.Absolute(left: 16.dp, right: 16.dp)) if let updateOptions = EnvironmentValues.shared._material3BottomAppBar { options = updateOptions(options) } @@ -588,21 +582,37 @@ public struct NavigationStack : View, Renderable { // We place nav bars within each entry rather than at the navigation controller level so toolbar preferences apply per entry. + let inheritedInsets = edgeInsets(from: EnvironmentValues.shared._contentWindowInsets) + let inheritedLeading = inheritedInsets.leading.dp + let inheritedTrailing = inheritedInsets.trailing.dp + let safeTopDp = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() + let topBarHeightDp = with(density) { topBarHeightPx.value.toDp() } + let topPadding = arguments.ignoresSafeAreaEdges.contains(.top) ? max(topBarHeightDp, safeTopDp) : topBarHeightDp + let bottomPadding = bottomBarHeightPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.bottom) ? + max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) : with(density) { bottomBarHeightPx.value.toDp() } + let chromePadding = PaddingValues(top: topPadding, bottom: bottomPadding) + let inheritedTop = inheritedInsets.top.dp + let inheritedBottom = inheritedInsets.bottom.dp + let contentInsets = contentWindowInsets(top: inheritedTop + topPadding, leading: inheritedLeading, bottom: inheritedBottom + bottomPadding, trailing: inheritedTrailing) + var contentSystemBarEdges = EnvironmentValues.shared._presentationSystemBarEdges + if showTopBar { + contentSystemBarEdges.remove(.top) + } + if bottomBarHeightPx.value > Float(0.0) { + contentSystemBarEdges.remove(.bottom) + } + let layoutImplementationVersion = EnvironmentValues.shared._layoutImplementationVersion if layoutImplementationVersion < 2 { // Old Column layout (version < 2) Column(modifier: modifier.background(Color.background.colorImpl())) { - // Calculate safe area for content - let contentSafeArea = arguments.safeArea? - .insetting(.top, to: topBarBottomPx.value) - .insetting(.bottom, to: bottomBarTopPx.value) // Inset manually for any edge where our container ignored the safe area, but we aren't showing a bar - let topPadding = topBarBottomPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.top) ? WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() : 0.dp - var bottomPadding = 0.dp - if bottomBarTopPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.bottom) { - bottomPadding = max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) - } - let contentModifier = Modifier.fillMaxWidth().weight(Float(1.0)).padding(top: topPadding, bottom: bottomPadding) + let layoutTopPadding = !showTopBar && arguments.ignoresSafeAreaEdges.contains(.top) ? safeTopDp : 0.dp + let layoutBottomPadding = bottomBarHeightPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.bottom) ? bottomPadding : 0.dp + let consumePadding = PaddingValues(top: contentSystemBarEdges.contains(.top) ? 0.dp : topPadding, bottom: contentSystemBarEdges.contains(.bottom) ? 0.dp : bottomPadding) + let contentModifier = Modifier.fillMaxWidth().weight(Float(1.0)) + .padding(top: layoutTopPadding, bottom: layoutBottomPadding) + .consumeWindowInsets(consumePadding) topBar() Box(modifier: contentModifier, contentAlignment: androidx.compose.ui.Alignment.Center) { @@ -624,9 +634,8 @@ public struct NavigationStack : View, Renderable { topPadding = searchFieldPlaceholderPadding } EnvironmentValues.shared.setValues { - if let contentSafeArea { - $0.set_safeArea(contentSafeArea) - } + $0.set_contentWindowInsets(contentInsets) + $0.set_presentationSystemBarEdges(contentSystemBarEdges) $0.set_searchableState(searchableState) $0.set_isNavigationRoot(arguments.isRoot) $0.set_nestedScrollConnection(scrollBehavior.nestedScrollConnection) @@ -645,15 +654,6 @@ public struct NavigationStack : View, Renderable { } else { // New Box layout (version >= 2) Box(modifier: modifier.background(Color.background.colorImpl()).fillMaxSize()) { - // Calculate safe area for content by insetting by topBar and bottomBar heights - var contentSafeArea: SafeArea? - if let safeArea = arguments.safeArea { - let clampedTopBarBottomPxValue: Float = max(topBarBottomPx.value, safeArea.safeBoundsPx.top) - contentSafeArea = safeArea - .insetting(.top, to: clampedTopBarBottomPxValue) - .insetting(.bottom, to: bottomBarTopPx.value) - } - // Top bar aligned to top Box(modifier: Modifier.zIndex(Float(1.1)).align(androidx.compose.ui.Alignment.TopCenter)) { topBar() @@ -666,17 +666,12 @@ public struct NavigationStack : View, Renderable { // Constrain the content to the area between the top bar and bottom bar. In the Box layout we use // fillMaxSize(), so we must add top/bottom padding to reserve space for our nav bars. Use the - // measured topBarBottomPx and bottomBarHeightPx when the bars are visible. When a bar is hidden, + // measured topBarHeightPx and bottomBarHeightPx when the bars are visible. When a bar is hidden, // inset by the system safe area (WindowInsets.safeDrawing) for that edge when // arguments.ignoresSafeAreaEdges contains it, so content does not overlap the status bar or home // indicator. - var contentModifier = Modifier.fillMaxSize() - let safeTopDp = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() - let topBarHeightDp = with(density) { topBarHeightPx.value.toDp() } - let topPadding = arguments.ignoresSafeAreaEdges.contains(.top) ? max(topBarHeightDp, safeTopDp) : topBarHeightDp - let bottomPadding = bottomBarHeightPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.bottom) ? - max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) : with(density) { bottomBarHeightPx.value.toDp() } - contentModifier = contentModifier.padding(top: topPadding, bottom: bottomPadding) + let consumePadding = PaddingValues(top: contentSystemBarEdges.contains(.top) ? 0.dp : topPadding, bottom: contentSystemBarEdges.contains(.bottom) ? 0.dp : bottomPadding) + let contentModifier = Modifier.fillMaxSize().padding(chromePadding).consumeWindowInsets(consumePadding) Box(modifier: contentModifier, contentAlignment: androidx.compose.ui.Alignment.Center) { var topPadding = 0.dp let searchableState: SearchableState? = arguments.isRoot ? (EnvironmentValues.shared._searchableState ?? searchableStatePreference.value.reduced) : nil @@ -696,9 +691,8 @@ public struct NavigationStack : View, Renderable { topPadding = searchFieldPlaceholderPadding } EnvironmentValues.shared.setValues { - if let contentSafeArea { - $0.set_safeArea(contentSafeArea) - } + $0.set_contentWindowInsets(contentInsets) + $0.set_presentationSystemBarEdges(contentSystemBarEdges) $0.set_searchableState(searchableState) $0.set_isNavigationRoot(arguments.isRoot) $0.set_nestedScrollConnection(scrollBehavior.nestedScrollConnection) @@ -721,12 +715,12 @@ public struct NavigationStack : View, Renderable { destination?(arguments.targetValue).Compose(context: context) } - @Composable private func useInlineTitleDisplayMode(for titleDisplayMode: ToolbarTitleDisplayMode, safeArea: SafeArea?) -> Bool { + @Composable private func useInlineTitleDisplayMode(for titleDisplayMode: ToolbarTitleDisplayMode) -> Bool { guard titleDisplayMode == .automatic else { return titleDisplayMode == ToolbarTitleDisplayMode.inline } // Default to inline if in landscape or a sheet - if let safeArea, safeArea.presentationBoundsPx.width > safeArea.presentationBoundsPx.height { + if LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE { return true } return EnvironmentValues.shared._sheetDepth > 0 @@ -754,7 +748,6 @@ public struct SkipNavigationStackPushKey : NavKey, Hashable { @Stable struct NavigationEntryArguments: Equatable { let isRoot: Bool let state: Navigator.BackStackState - let safeArea: SafeArea? let ignoresSafeAreaEdges: Edge.Set let title: Text let toolbarPreferences: ToolbarPreferences diff --git a/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift b/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift index 3e7461e0..a47ff00d 100644 --- a/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift +++ b/Sources/SkipUI/SkipUI/Containers/PresentationRoot.swift @@ -6,6 +6,7 @@ import android.content.ContextWrapper import androidx.activity.ComponentActivity import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues @@ -13,17 +14,16 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection @@ -37,12 +37,28 @@ import androidx.compose.ui.platform.LocalLayoutDirection PreferenceValues.shared.collectPreferences([preferredColorSchemeCollector]) { let materialColorScheme = preferredColorScheme.value.reduced.colorScheme?.asMaterialTheme() ?? defaultColorScheme?.asMaterialTheme() ?? MaterialTheme.colorScheme MaterialTheme(colorScheme: materialColorScheme) { - let presentationBounds = remember { mutableStateOf(Rect.Zero) } let density = LocalDensity.current let layoutDirection = LocalLayoutDirection.current + let safeDrawing = WindowInsets.safeDrawing + let systemBars = WindowInsets.systemBars + let isRTL = layoutDirection == androidx.compose.ui.unit.LayoutDirection.Rtl + let safeLeftPx = systemBarEdges.contains(isRTL ? .trailing : .leading) ? safeDrawing.getLeft(density, layoutDirection) : 0 + let safeRightPx = systemBarEdges.contains(isRTL ? .leading : .trailing) ? safeDrawing.getRight(density, layoutDirection) : 0 + let safeTopPx = systemBarEdges.contains(.top) ? safeDrawing.getTop(density) : 0 + // The keyboard is handled by `imePadding` below, so don't reserve the navigation bar it covers + let safeBottomPx = systemBarEdges.contains(.bottom) ? max(0, systemBars.getBottom(density) - WindowInsets.ime.getBottom(density)) : 0 + let contentInsets = with(density) { + contentWindowInsets( + top: safeTopPx.toDp(), + leading: (isRTL ? safeRightPx : safeLeftPx).toDp(), + bottom: safeBottomPx.toDp(), + trailing: (isRTL ? safeLeftPx : safeRightPx).toDp() + ) + } var rootModifier = Modifier .background(androidx.compose.ui.graphics.Color.Black) .fillMaxSize() + // We pad horizontally like standard Android apps do, so we can consume those insets if systemBarEdges.contains(.leading) { rootModifier = rootModifier.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Start)) } @@ -53,32 +69,17 @@ import androidx.compose.ui.platform.LocalLayoutDirection rootModifier = rootModifier.imePadding() } rootModifier = rootModifier.background(Color.background.colorImpl()) - .onGloballyPositionedInWindow { - presentationBounds.value = $0 - } - Box(modifier: rootModifier) { - guard presentationBounds.value != Rect.Zero else { - return - } - // Cannot get accurate WindowInsets until we're in the content box. We only check top and bottom - // because we've padded the content to within horizontal safe insets already, mirroring standard - // Android app behavior like e.g. Settings - var (safeLeft, safeTop, safeRight, safeBottom) = presentationBounds.value - if systemBarEdges.contains(.top) { - safeTop += WindowInsets.safeDrawing.getTop(density) - } - if systemBarEdges.contains(.bottom) { - safeBottom -= max(0, WindowInsets.safeDrawing.getBottom(density) - WindowInsets.ime.getBottom(density)) - } - let safeBounds = Rect(left: safeLeft, top: safeTop, right: safeRight, bottom: safeBottom) - let safeArea = SafeArea(presentation: presentationBounds.value, safe: safeBounds, absoluteSystemBars: systemBarEdges) + // Reserve the vertical system bars, but with plain padding rather than `windowInsetsPadding`: + // containers like NavigationStack and TabView expand back into these edges, and their bars + // apply the system insets themselves. Consuming here would zero out that bar padding + let verticalPadding = with(density) { PaddingValues(top: safeTopPx.toDp(), bottom: safeBottomPx.toDp()) } + Box(modifier: rootModifier, contentAlignment: androidx.compose.ui.Alignment.Center) { EnvironmentValues.shared.setValues { - // Detect whether the app is edge to edge mode based on whether we're padding horizontally (landscape) - // or we have a top/bttom safe area (portrait) if $0._isEdgeToEdge == nil { - $0.set_isEdgeToEdge(safeBounds != presentationBounds.value) + $0.set_isEdgeToEdge(safeLeftPx > 0 || safeTopPx > 0 || safeRightPx > 0 || safeBottomPx > 0) } - $0.set_safeArea(safeArea) + $0.set_contentWindowInsets(contentInsets) + $0.set_presentationSystemBarEdges(systemBarEdges) // A presentation is a new layout root: scroll axes inherited from the presenting // context (e.g. a sheet presented from a button inside a ScrollView) must not // leak in. Otherwise expanding content in the presentation is sized with @@ -89,7 +90,7 @@ import androidx.compose.ui.platform.LocalLayoutDirection $0.set_scrollAxes(Axis.Set(rawValue: 0)) return ComposeResult.ok } in: { - Box(modifier: Modifier.fillMaxSize().padding(safeArea), contentAlignment = androidx.compose.ui.Alignment.Center) { + Box(modifier: Modifier.fillMaxSize().padding(verticalPadding), contentAlignment: androidx.compose.ui.Alignment.Center) { content(context) } } diff --git a/Sources/SkipUI/SkipUI/Containers/TabView.swift b/Sources/SkipUI/SkipUI/Containers/TabView.swift index 753ce231..e49ef8c8 100644 --- a/Sources/SkipUI/SkipUI/Containers/TabView.swift +++ b/Sources/SkipUI/SkipUI/Containers/TabView.swift @@ -11,9 +11,11 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -257,32 +259,21 @@ public struct TabView : View, Renderable { let tabBarPreferences = rememberSaveable(stateSaver: context.stateSaver as! Saver, Any>) { mutableStateOf(Preference(key: TabBarPreferenceKey.self)) } let tabBarPreferencesCollector = PreferenceCollector(key: TabBarPreferenceKey.self, state: tabBarPreferences) - let safeArea = EnvironmentValues.shared._safeArea - /// Latest TabView-scope safe area; use inside long-lived nav entry closures so inset updates (e.g. status bar hide) propagate without relying on lexical capture of `safeArea`. - let tabViewSafeAreaState = rememberUpdatedState(safeArea) let density = LocalDensity.current let defaultBottomBarHeight = 80.dp - let bottomBarTopPx = remember { - // Default our initial value to the expected value, which helps avoid visual artifacts as we measure actual values and - // recompose with adjusted layouts - if let safeArea { - mutableStateOf(with(density) { safeArea.presentationBoundsPx.bottom - defaultBottomBarHeight.toPx() }) - } else { - mutableStateOf(Float(0.0)) - } - } let bottomBarHeightPx = remember { mutableStateOf(with(density) { defaultBottomBarHeight.toPx() }) } - let tabNavLeadingEndPx = remember { mutableStateOf(Float(0.0)) } + let tabNavLeadingWidthPx = remember { mutableStateOf(Float(0.0)) } // Reduce the tab bar preferences outside the bar composable. Otherwise the reduced value may change // when the bottom bar recomposes let reducedTabBarPreferences = tabBarPreferences.value.reduced + let showsTabBar = tabs.any({ $0 != nil }) && reducedTabBarPreferences.visibility != Visibility.hidden // When we layout, extend into the safe area if it is due to system bars, not into any app chrome. We extend // into the top bar too so that tab content can also extend into the top area without getting cut off during // tab switches var ignoresSafeAreaEdges: Edge.Set = [.bottom, .top] - ignoresSafeAreaEdges.formIntersection(safeArea?.absoluteSystemBarEdges ?? []) + ignoresSafeAreaEdges.formIntersection(EnvironmentValues.shared._presentationSystemBarEdges) IgnoresSafeAreaLayout(expandInto: ignoresSafeAreaEdges, checkEdges: ignoresSafeAreaEdges, logTag: "TabView") { _, _ in ComposeContainer(modifier: context.modifier, fillWidth: true, fillHeight: true) { modifier in // Don't use a Scaffold: it clips content beyond its bounds and prevents .ignoresSafeArea modifiers from working @@ -299,11 +290,10 @@ public struct TabView : View, Renderable { let navigationSuiteScaffoldState = rememberNavigationSuiteScaffoldState() NavigationSuiteScaffoldLayout( navigationSuite: { - guard tabs.any({ $0 != nil }) && reducedTabBarPreferences.visibility != Visibility.hidden else { + guard showsTabBar else { SideEffect { - bottomBarTopPx.value = Float(0.0) bottomBarHeightPx.value = Float(0.0) - tabNavLeadingEndPx.value = Float(0.0) + tabNavLeadingWidthPx.value = Float(0.0) } return } @@ -311,17 +301,14 @@ public struct TabView : View, Renderable { .onGloballyPositionedInWindow { bounds in let lt = layoutTypeState.value if lt == NavigationSuiteType.NavigationBar { - bottomBarTopPx.value = bounds.top bottomBarHeightPx.value = bounds.bottom - bounds.top - tabNavLeadingEndPx.value = Float(0.0) + tabNavLeadingWidthPx.value = Float(0.0) } else if lt == NavigationSuiteType.NavigationRail { - bottomBarTopPx.value = Float(0.0) bottomBarHeightPx.value = Float(0.0) - tabNavLeadingEndPx.value = bounds.right + tabNavLeadingWidthPx.value = bounds.width } else { - bottomBarTopPx.value = Float(0.0) bottomBarHeightPx.value = Float(0.0) - tabNavLeadingEndPx.value = Float(0.0) + tabNavLeadingWidthPx.value = Float(0.0) } } .semantics { testTagsAsResourceId = true }.testTag("skip_ui_automation_tab_bar") @@ -489,17 +476,41 @@ public struct TabView : View, Renderable { let tabKey = key as! SkipTabViewRouteKey return NavEntry(tabKey, content: { key in let tabIndex = (key as! SkipTabViewRouteKey).index - // Inset manually where our container ignored the safe area, but we aren't showing a bar - let topPadding = ignoresSafeAreaEdges.contains(.top) ? WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() : 0.dp - var bottomPadding = 0.dp - if bottomBarTopPx.value <= Float(0.0) && ignoresSafeAreaEdges.contains(.bottom) { - bottomPadding = max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) + // NavigationSuiteScaffoldLayout already sizes the content slot + // around the navigation suite. Only pad for system edges the + // suite is not covering; always publish/consume chrome insets + // for GeometryProxy and descendants. + let systemTopPadding = ignoresSafeAreaEdges.contains(.top) ? WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() : 0.dp + let systemBottomPadding = ignoresSafeAreaEdges.contains(.bottom) ? max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) : 0.dp + let tabBarHeightDp = with(density) { bottomBarHeightPx.value.toDp() } + let railWidthDp = with(density) { tabNavLeadingWidthPx.value.toDp() } + let layoutTopPadding = systemTopPadding + let layoutBottomPadding = showsTabBar ? 0.dp : systemBottomPadding + let chromeBottomForInsets = showsTabBar && bottomBarHeightPx.value > Float(0.0) ? tabBarHeightDp : layoutBottomPadding + let chromeLeadingForInsets = railWidthDp + // NavigationSuiteScaffoldLayout already subtracts suite size from the content slot. + let layoutPadding = PaddingValues(top: layoutTopPadding, bottom: layoutBottomPadding) + // Only consume the edges the tab bar or rail covers. The top is merely padded for + // the status bar, which a nav stack expands back into and pads with its own top bar + let consumePadding = PaddingValues(start: chromeLeadingForInsets, bottom: showsTabBar ? chromeBottomForInsets : 0.dp) + var contentModifier = Modifier.fillMaxSize() + if layoutTopPadding > 0.dp || layoutBottomPadding > 0.dp { + contentModifier = contentModifier.padding(layoutPadding) } - let contentModifier = Modifier.fillMaxSize().padding(top: topPadding, bottom: bottomPadding) - let tabViewSafeArea = tabViewSafeAreaState.value - var contentSafeArea = tabViewSafeArea?.insetting(Edge.bottom, to: bottomBarTopPx.value) - if tabNavLeadingEndPx.value > Float(0.0) { - contentSafeArea = contentSafeArea?.insetting(Edge.leading, to: tabNavLeadingEndPx.value) + contentModifier = contentModifier.consumeWindowInsets(consumePadding) + let inheritedInsets = edgeInsets(from: EnvironmentValues.shared._contentWindowInsets) + let contentInsets = contentWindowInsets( + top: inheritedInsets.top.dp + layoutTopPadding, + leading: inheritedInsets.leading.dp + chromeLeadingForInsets, + bottom: inheritedInsets.bottom.dp + chromeBottomForInsets, + trailing: inheritedInsets.trailing.dp + ) + var contentSystemBarEdges = EnvironmentValues.shared._presentationSystemBarEdges + if showsTabBar && bottomBarHeightPx.value > Float(0.0) { + contentSystemBarEdges.remove(.bottom) + } + if tabNavLeadingWidthPx.value > Float(0.0) { + contentSystemBarEdges.remove(.leading) } // Special-case the first composition to avoid seeing the layout adjust. This is a common @@ -511,7 +522,7 @@ public struct TabView : View, Renderable { Box(modifier: Modifier.alpha(alpha), contentAlignment: androidx.compose.ui.Alignment.Center) { // This block is called multiple times on tab switch. Use stable arguments that will prevent our entry from // recomposing when called with the same values - let arguments = TabEntryArguments(tabIndex: tabIndex, modifier: contentModifier, safeArea: contentSafeArea) + let arguments = TabEntryArguments(tabIndex: tabIndex, modifier: contentModifier, contentInsets: contentInsets, systemBarEdges: contentSystemBarEdges) PreferenceValues.shared.collectPreferences([tabBarPreferencesCollector]) { RenderEntry(with: arguments, context: entryContext) } @@ -566,9 +577,8 @@ public struct TabView : View, Renderable { // multiple times for the same tab on tab change. Test after modifications Box(modifier: arguments.modifier, contentAlignment: androidx.compose.ui.Alignment.Center) { EnvironmentValues.shared.setValues { - if let safeArea = arguments.safeArea { - $0.set_safeArea(safeArea) - } + $0.set_contentWindowInsets(arguments.contentInsets) + $0.set_presentationSystemBarEdges(arguments.systemBarEdges) return ComposeResult.ok } in: { let renderables = EvaluateContent(context: context) @@ -661,7 +671,8 @@ public struct SkipTabViewRouteKey : NavKey { @Stable struct TabEntryArguments: Equatable { let tabIndex: Int let modifier: Modifier - let safeArea: SafeArea? + let contentInsets: WindowInsets + let systemBarEdges: Edge.Set } struct TabBarPreferenceKey: PreferenceKey { diff --git a/Sources/SkipUI/SkipUI/Containers/Table.swift b/Sources/SkipUI/SkipUI/Containers/Table.swift index 400cf0d5..281e21cd 100644 --- a/Sources/SkipUI/SkipUI/Containers/Table.swift +++ b/Sources/SkipUI/SkipUI/Containers/Table.swift @@ -55,9 +55,8 @@ public final class Table : View, Renderable where ObjectType: Id @Composable override func Render(context: ComposeContext) { // When we layout, extend into safe areas that are due to system bars, not into any app chrome. We'll add // blank head - let safeArea = EnvironmentValues.shared._safeArea var ignoresSafeAreaEdges: Edge.Set = [.top, .bottom] - ignoresSafeAreaEdges.formIntersection(safeArea?.absoluteSystemBarEdges ?? []) + ignoresSafeAreaEdges.formIntersection(EnvironmentValues.shared._presentationSystemBarEdges) let itemContext = context.content() ComposeContainer(scrollAxes: .vertical, modifier: context.modifier, fillWidth: true, fillHeight: true) { modifier in IgnoresSafeAreaLayout(expandInto: ignoresSafeAreaEdges, modifier: modifier, logTag: "Table") { safeAreaExpansion, _ in diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index 0dc44bf7..951eaa84 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -789,6 +789,12 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_contentMargins", value: newValue, defaultValue: { nil }) } } + /// Insets reserved by presentation and app chrome for SwiftUI safe-area semantics. + var _contentWindowInsets: WindowInsets { + get { builtinValue(key: "_contentWindowInsets", defaultValue: { WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) }) as! WindowInsets } + set { setBuiltinValue(key: "_contentWindowInsets", value: newValue, defaultValue: { WindowInsets(0.dp, 0.dp, 0.dp, 0.dp) }) } + } + var _listRowSpacing: CGFloat? { get { builtinValue(key: "_listRowSpacing", defaultValue: { nil }) as! CGFloat? } set { setBuiltinValue(key: "_listRowSpacing", value: newValue, defaultValue: { nil }) } @@ -912,9 +918,10 @@ extension EnvironmentValues { set { setBuiltinValue(key: "_tabViewTransitions", value: newValue, defaultValue: { nil }) } } - var _safeArea: SafeArea? { - get { builtinValue(key: "_safeArea", defaultValue: { nil }) as! SafeArea? } - set { setBuiltinValue(key: "_safeArea", value: newValue, defaultValue: { nil }) } + /// Edges at the current presentation root whose safe area comes from system bars. + var _presentationSystemBarEdges: Edge.Set { + get { builtinValue(key: "_presentationSystemBarEdges", defaultValue: { Edge.Set(rawValue: 0) }) as! Edge.Set } + set { setBuiltinValue(key: "_presentationSystemBarEdges", value: newValue, defaultValue: { Edge.Set(rawValue: 0) }) } } var _scrollAxes: Axis.Set { diff --git a/Sources/SkipUI/SkipUI/Layout/GeometryProxy.swift b/Sources/SkipUI/SkipUI/Layout/GeometryProxy.swift index da5d1819..c4c8714c 100644 --- a/Sources/SkipUI/SkipUI/Layout/GeometryProxy.swift +++ b/Sources/SkipUI/SkipUI/Layout/GeometryProxy.swift @@ -2,8 +2,10 @@ // SPDX-License-Identifier: MPL-2.0 #if !SKIP_BRIDGE #if SKIP +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.ui.geometry.Rect import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection #elseif canImport(CoreGraphics) import struct CoreGraphics.CGFloat import struct CoreGraphics.CGRect @@ -15,7 +17,8 @@ public struct GeometryProxy { #if SKIP let globalFramePx: Rect let density: Density - let safeArea: SafeArea? + let contentWindowInsets: WindowInsets + let layoutDirection: LayoutDirection #endif public var size: CGSize { @@ -47,17 +50,17 @@ public struct GeometryProxy { public var safeAreaInsets: EdgeInsets { #if SKIP - guard let safeArea = safeArea else { - return EdgeInsets() - } + let top = contentWindowInsets.getTop(density) + let left = contentWindowInsets.getLeft(density, layoutDirection) + let bottom = contentWindowInsets.getBottom(density) + let right = contentWindowInsets.getRight(density, layoutDirection) + let isRTL = layoutDirection == LayoutDirection.Rtl return with(density) { - let presentation = safeArea.presentationBoundsPx - let safe = safeArea.safeBoundsPx return EdgeInsets( - top: Double((safe.top - presentation.top).toDp().value), - leading: Double((safe.left - presentation.left).toDp().value), - bottom: Double((presentation.bottom - safe.bottom).toDp().value), - trailing: Double((presentation.right - safe.right).toDp().value) + top: Double(Float(top).toDp().value), + leading: Double(Float(isRTL ? right : left).toDp().value), + bottom: Double(Float(bottom).toDp().value), + trailing: Double(Float(isRTL ? left : right).toDp().value) ) } #else diff --git a/Sources/SkipUI/SkipUI/Layout/GeometryReader.swift b/Sources/SkipUI/SkipUI/Layout/GeometryReader.swift index 2828d3cb..27cfee02 100644 --- a/Sources/SkipUI/SkipUI/Layout/GeometryReader.swift +++ b/Sources/SkipUI/SkipUI/Layout/GeometryReader.swift @@ -11,6 +11,7 @@ import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInParent import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection #endif // SKIP @bridge @@ -29,7 +30,7 @@ public struct GeometryReader : View, Renderable { rememberedGlobalFramePx.value = $0 }) { if let globalFramePx = rememberedGlobalFramePx.value { - let proxy = GeometryProxy(globalFramePx: globalFramePx, density: LocalDensity.current, safeArea: EnvironmentValues.shared._safeArea) + let proxy = GeometryProxy(globalFramePx: globalFramePx, density: LocalDensity.current, contentWindowInsets: EnvironmentValues.shared._contentWindowInsets, layoutDirection: LocalLayoutDirection.current) content(proxy).Compose(context.content()) } } diff --git a/Sources/SkipUI/SkipUI/Layout/SafeArea.swift b/Sources/SkipUI/SkipUI/Layout/SafeArea.swift index e34abb2b..a88da198 100644 --- a/Sources/SkipUI/SkipUI/Layout/SafeArea.swift +++ b/Sources/SkipUI/SkipUI/Layout/SafeArea.swift @@ -1,10 +1,7 @@ // Copyright 2023–2026 Skip // SPDX-License-Identifier: MPL-2.0 #if !SKIP_BRIDGE -#if SKIP -import androidx.compose.runtime.Composable -import androidx.compose.ui.geometry.Rect -#elseif canImport(CoreGraphics) +#if canImport(CoreGraphics) import struct CoreGraphics.CGFloat #endif @@ -20,56 +17,6 @@ public struct SafeAreaRegions : OptionSet { public static let all = SafeAreaRegions(rawValue: 3) } -#if SKIP -import androidx.compose.ui.geometry.Rect - -/// Track safe area. -struct SafeArea: Equatable, CustomStringConvertible { - /// Total bounds of presentation root. - let presentationBoundsPx: Rect - - /// Safe bounds of presentation root. - let safeBoundsPx: Rect - - /// The edges whose safe area is solely due to system bars. - let absoluteSystemBarEdges: Edge.Set - - init(presentation: Rect, safe: Rect, absoluteSystemBars: Edge.Set = []) { - self.presentationBoundsPx = presentation - self.safeBoundsPx = safe - self.absoluteSystemBarEdges = absoluteSystemBars - } - - /// Update the safe area. - @Composable func insetting(_ edge: Edge, to value: Float) -> SafeArea { - guard value > Float(0.0) else { - return self - } - var systemBarEdges = absoluteSystemBarEdges - var (safeLeft, safeTop, safeRight, safeBottom) = safeBoundsPx - switch edge { - case .top: - safeTop = value - systemBarEdges.remove(.top) - case .bottom: - safeBottom = value - systemBarEdges.remove(.bottom) - case .leading: - safeLeft = value - systemBarEdges.remove(.leading) - case .trailing: - safeRight = value - systemBarEdges.remove(.trailing) - } - return SafeArea(presentation: presentationBoundsPx, safe: Rect(top: safeTop, left: safeLeft, bottom: safeBottom, right: safeRight), absoluteSystemBars: systemBarEdges) - } - - var description: String { - "SafeArea(presentationBoundsPx: \(presentationBoundsPx), safeBoundsPx: \(safeBoundsPx), absoluteSystemBarEdges: \(absoluteSystemBarEdges))" - } -} -#endif - extension View { @available(*, unavailable) public func safeAreaInset(edge: VerticalEdge, alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> any View) -> some View { diff --git a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift index 6899f2e9..9f8c2fee 100644 --- a/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift +++ b/Sources/SkipUI/SkipUI/View/AdditionalViewModifiers.swift @@ -1013,7 +1013,7 @@ extension View { let density = LocalDensity.current if let rect = globalFramePx.value { - let proxy = GeometryProxy(globalFramePx: rect, density: density, safeArea: EnvironmentValues.shared._safeArea) + let proxy = GeometryProxy(globalFramePx: rect, density: density, contentWindowInsets: EnvironmentValues.shared._contentWindowInsets, layoutDirection: LocalLayoutDirection.current) let newValue = transform(proxy) let oldValue = previousValue.value as? T if oldValue == nil || oldValue != newValue { From c8916a1f3ed4a3f09d5ae5fe7f0245383fe6d902 Mon Sep 17 00:00:00 2001 From: Dan Fabulich Date: Sat, 25 Jul 2026 21:13:59 -0700 Subject: [PATCH 2/2] Make `NavigationStack` use `SubcomposeLayout` This eliminates the need to use `onGloballyPositioned` to measure the app bar, eliminating layout shift. --- README.md | 4 +- .../SkipUI/SkipUI/Containers/Navigation.swift | 314 +++++++++++------- .../Environment/EnvironmentValues.swift | 4 +- 3 files changed, 204 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index fe30569b..1900195d 100644 --- a/README.md +++ b/README.md @@ -3050,12 +3050,12 @@ SkipUI fully supports SwiftUI's various layout mechanisms, including `HStack`, ` - Expanding elements such as `Spacer` or `.frame(maxWidth: .infinity)` within nested `HStacks` or `VStacks` may measure differently. Try un-nesting stacks to get more SwiftUI-like layout. - Views with `.frame(maxWidth:)` or `.frame(maxHeight:)` set to explicit values larger than the parent's actual size may expand beyond the parent container's bounds on Android. To work around this, use a `GeometryReader` to compute the parent's size and set an explicit `.frame(width:)` or `.frame(height:)` instead of `maxWidth` or `maxHeight`. See [Issue #339](https://github.com/skiptools/skip-ui/issues/339). -Note: if your app was developed under an earlier version of Skip and it relies on nuances of older layout behavior, you can apply the Android-only `.layoutImplementationVersion()` modifier. Set this modifier on a `View` hierarchy to simulate the previous behavior: +Note: if your app was developed under an earlier version of Skip and it relies on nuances of older layout behavior, you can apply the Android-only `.layoutImplementationVersion()` modifier. Set this modifier on a `View` hierarchy to simulate the previous behavior. The current default is `3` (`NavigationStack` uses a Scaffold-style `SubcomposeLayout` for top/bottom bars). Use `2` for the previous `NavigationStack` Box overlay layout, or `0`/`1` for older stack spacing behavior: ```swift SomeRootView() #if os(Android) - .layoutImplementationVersion(0) + .layoutImplementationVersion(2) #endif ``` diff --git a/Sources/SkipUI/SkipUI/Containers/Navigation.swift b/Sources/SkipUI/SkipUI/Containers/Navigation.swift index eb7ee9f1..044f2807 100644 --- a/Sources/SkipUI/SkipUI/Containers/Navigation.swift +++ b/Sources/SkipUI/SkipUI/Containers/Navigation.swift @@ -77,6 +77,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalConfiguration @@ -319,16 +320,21 @@ public struct NavigationStack : View, Renderable { } modifier = modifier.then(context.modifier) + let layoutImplementationVersion = EnvironmentValues.shared._layoutImplementationVersion + let useLegacyBarHeightTracking = layoutImplementationVersion < 3 + let defaultTopBarHeight = 112.dp let topBarHeightPx = remember { mutableStateOf(showTopBar ? with(density) { defaultTopBarHeight.toPx() } : Float(0.0)) } // Reactively clear the reserved inset whenever the bar is hidden — covers both a // title-less root (where AnimatedVisibility's onDispose never runs) and visible→hidden - // transitions where onDispose may not have fired yet. - LaunchedEffect(showTopBar) { - if !showTopBar { - topBarHeightPx.value = Float(0.0) + // transitions where onDispose may not have fired yet. Only needed for legacy layouts. + if useLegacyBarHeightTracking { + LaunchedEffect(showTopBar) { + if !showTopBar { + topBarHeightPx.value = Float(0.0) + } } } @@ -340,9 +346,11 @@ public struct NavigationStack : View, Renderable { let topBarEnter = moveEdgeTop.asEnterTransition(spec: animationSpec) let topBarExit = moveEdgeTop.asExitTransition(spec: animationSpec) AnimatedVisibility(visible: showTopBar, modifier: Modifier.fillMaxWidth(), enter: topBarEnter, exit: topBarExit, label: "NavigationTopBar") { - DisposableEffect(true) { - onDispose { - topBarHeightPx.value = Float(0.0) + if useLegacyBarHeightTracking { + DisposableEffect(true) { + onDispose { + topBarHeightPx.value = Float(0.0) + } } } let isOverlapped = scrollBehavior.state.overlappedFraction > 0 @@ -392,9 +400,11 @@ public struct NavigationStack : View, Renderable { .clickable(interactionSource: interactionSource, indication: nil, onClick: { scrollToTop.value.reduced.action() }) - .onGloballyPositionedInWindow { bounds in + if useLegacyBarHeightTracking { + topBarModifier = topBarModifier.onGloballyPositionedInWindow { bounds in topBarHeightPx.value = bounds.bottom - bounds.top } + } if !topBarHasColorScheme || isOverlapped, let topBarBackgroundForBrush { let opacity = topBarHasColorScheme ? 1.0 : isInlineTitleDisplayMode ? min(1.0, Double(scrollBehavior.state.overlappedFraction * 5)) : Double(scrollBehavior.state.collapsedFraction) if let topBarBackgroundBrush = topBarBackgroundForBrush.asBrush(opacity: opacity, animationContext: nil) { @@ -491,6 +501,7 @@ public struct NavigationStack : View, Renderable { } } + // Kept for bottom-bar IME pull (all versions) and legacy chrome padding (versions < 3). let bottomBarHeightPx = remember { mutableStateOf(Float(0.0)) } let bottomBar: @Composable () -> Void = { guard bottomBarPreferences?.visibility != Visibility.hidden else { @@ -585,126 +596,189 @@ public struct NavigationStack : View, Renderable { let inheritedInsets = edgeInsets(from: EnvironmentValues.shared._contentWindowInsets) let inheritedLeading = inheritedInsets.leading.dp let inheritedTrailing = inheritedInsets.trailing.dp - let safeTopDp = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() - let topBarHeightDp = with(density) { topBarHeightPx.value.toDp() } - let topPadding = arguments.ignoresSafeAreaEdges.contains(.top) ? max(topBarHeightDp, safeTopDp) : topBarHeightDp - let bottomPadding = bottomBarHeightPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.bottom) ? - max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) : with(density) { bottomBarHeightPx.value.toDp() } - let chromePadding = PaddingValues(top: topPadding, bottom: bottomPadding) let inheritedTop = inheritedInsets.top.dp let inheritedBottom = inheritedInsets.bottom.dp - let contentInsets = contentWindowInsets(top: inheritedTop + topPadding, leading: inheritedLeading, bottom: inheritedBottom + bottomPadding, trailing: inheritedTrailing) - var contentSystemBarEdges = EnvironmentValues.shared._presentationSystemBarEdges - if showTopBar { - contentSystemBarEdges.remove(.top) - } - if bottomBarHeightPx.value > Float(0.0) { - contentSystemBarEdges.remove(.bottom) + let safeTopDp = WindowInsets.safeDrawing.asPaddingValues().calculateTopPadding() + let safeBottomDp = max(0.dp, WindowInsets.safeDrawing.asPaddingValues().calculateBottomPadding() - WindowInsets.ime.asPaddingValues().calculateBottomPadding()) + + let renderMainContent: @Composable (PaddingValues, WindowInsets, Edge.Set) -> Void = { chromePadding, contentInsets, contentSystemBarEdges in + // Only consume the edges our bars cover. Where we're merely padding for a system bar that + // descendants can expand back into, consuming would suppress their own inset padding + let consumePadding = PaddingValues(top: contentSystemBarEdges.contains(.top) ? 0.dp : chromePadding.calculateTopPadding(), bottom: contentSystemBarEdges.contains(.bottom) ? 0.dp : chromePadding.calculateBottomPadding()) + Box(modifier: Modifier.fillMaxSize().padding(chromePadding).consumeWindowInsets(consumePadding), contentAlignment: androidx.compose.ui.Alignment.Center) { + var searchTopPadding = 0.dp + let searchableState: SearchableState? = arguments.isRoot ? (EnvironmentValues.shared._searchableState ?? searchableStatePreference.value.reduced) : nil + if let searchableState { + let searchFieldBackground = isSystemBackground ? Color.systemBarBackground.colorImpl() : androidx.compose.ui.graphics.Color.Transparent + let searchFieldFadeOffset = searchFieldHeightPx / 3 + let searchFieldModifier = Modifier.height(searchFieldHeight.dp + searchFieldPadding) + .align(androidx.compose.ui.Alignment.TopCenter) + .offset({ IntOffset(0, Int(searchFieldOffsetPx.value)) }) + .background(searchFieldBackground) + .padding(start: searchFieldPadding, bottom: searchFieldPadding, end: searchFieldPadding) + // Offset is negative. Fade out quickly as it scrolls in case it is moving up under transparent nav bar + .graphicsLayer { alpha = max(Float(0.0), (searchFieldFadeOffset + searchFieldOffsetPx.value) / searchFieldFadeOffset) } + .fillMaxWidth() + SearchField(state: searchableState, context: context.content(modifier: searchFieldModifier)) + let searchFieldPlaceholderPadding = searchFieldHeight.dp + searchFieldPadding + (with(LocalDensity.current) { searchFieldOffsetPx.value.toDp() }) + searchTopPadding = searchFieldPlaceholderPadding + } + EnvironmentValues.shared.setValues { + $0.set_contentWindowInsets(contentInsets) + $0.set_presentationSystemBarEdges(contentSystemBarEdges) + $0.set_searchableState(searchableState) + $0.set_isNavigationRoot(arguments.isRoot) + $0.set_nestedScrollConnection(scrollBehavior.nestedScrollConnection) + return ComposeResult.ok + } in: { + // Elevate the top padding modifier so that content always has the same context, allowing it to avoid recomposition + Box(modifier: Modifier.padding(top: searchTopPadding)) { + PreferenceValues.shared.collectPreferences([searchableStateCollector, scrollToTopCollector]) { + content(context.content()) + } + } + } + } } - let layoutImplementationVersion = EnvironmentValues.shared._layoutImplementationVersion - if layoutImplementationVersion < 2 { - // Old Column layout (version < 2) - Column(modifier: modifier.background(Color.background.colorImpl())) { - // Inset manually for any edge where our container ignored the safe area, but we aren't showing a bar - let layoutTopPadding = !showTopBar && arguments.ignoresSafeAreaEdges.contains(.top) ? safeTopDp : 0.dp - let layoutBottomPadding = bottomBarHeightPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.bottom) ? bottomPadding : 0.dp - let consumePadding = PaddingValues(top: contentSystemBarEdges.contains(.top) ? 0.dp : topPadding, bottom: contentSystemBarEdges.contains(.bottom) ? 0.dp : bottomPadding) - let contentModifier = Modifier.fillMaxWidth().weight(Float(1.0)) - .padding(top: layoutTopPadding, bottom: layoutBottomPadding) - .consumeWindowInsets(consumePadding) - - topBar() - Box(modifier: contentModifier, contentAlignment: androidx.compose.ui.Alignment.Center) { - var topPadding = 0.dp - let searchableState: SearchableState? = arguments.isRoot ? (EnvironmentValues.shared._searchableState ?? searchableStatePreference.value.reduced) : nil - if let searchableState { - let searchFieldBackground = isSystemBackground ? Color.systemBarBackground.colorImpl() : androidx.compose.ui.graphics.Color.Transparent - let searchFieldFadeOffset = searchFieldHeightPx / 3 - let searchFieldModifier = Modifier.height(searchFieldHeight.dp + searchFieldPadding) - .align(androidx.compose.ui.Alignment.TopCenter) - .offset({ IntOffset(0, Int(searchFieldOffsetPx.value)) }) - .background(searchFieldBackground) - .padding(start: searchFieldPadding, bottom: searchFieldPadding, end: searchFieldPadding) - // Offset is negative. Fade out quickly as it scrolls in case it is moving up under transparent nav bar - .graphicsLayer { alpha = max(Float(0.0), (searchFieldFadeOffset + searchFieldOffsetPx.value) / searchFieldFadeOffset) } - .fillMaxWidth() - SearchField(state: searchableState, context: context.content(modifier: searchFieldModifier)) - let searchFieldPlaceholderPadding = searchFieldHeight.dp + searchFieldPadding + (with(LocalDensity.current) { searchFieldOffsetPx.value.toDp() }) - topPadding = searchFieldPlaceholderPadding + if layoutImplementationVersion >= 3 { + // Scaffold-style SubcomposeLayout: measure bars, then pad content in the same layout pass. + // Do not remember the slot lambdas — Skip may treat closures as equal and freeze the + // first composition's showTopBar / inherited insets (unlike Compose reference equality). + let chromePaddingHolder = remember { NavigationChromePaddingHolder() } + SubcomposeLayout(modifier: modifier.background(Color.background.colorImpl()).fillMaxSize()) { constraints in + let layoutWidth = constraints.maxWidth + let layoutHeight = constraints.maxHeight + let looseConstraints = constraints.copy(minWidth: 0, minHeight: 0) + + let topBarPlaceable = subcompose(NavigationScaffoldSlot.topBar) { + Box { topBar() } + }.first().measure(looseConstraints) + let bottomBarPlaceable = subcompose(NavigationScaffoldSlot.bottomBar) { + Box { bottomBar() } + }.first().measure(looseConstraints) + let hasTopBar = topBarPlaceable.width > 0 || topBarPlaceable.height > 0 + let hasBottomBar = bottomBarPlaceable.width > 0 || bottomBarPlaceable.height > 0 + + let topPad: Dp + if hasTopBar { + topPad = topBarPlaceable.height.toDp() + } else if arguments.ignoresSafeAreaEdges.contains(.top) { + topPad = safeTopDp + } else { + topPad = 0.dp + } + let bottomPad: Dp + if hasBottomBar { + bottomPad = bottomBarPlaceable.height.toDp() + } else if arguments.ignoresSafeAreaEdges.contains(.bottom) { + bottomPad = safeBottomDp + } else { + bottomPad = 0.dp + } + chromePaddingHolder.top = topPad + chromePaddingHolder.bottom = bottomPad + chromePaddingHolder.hasTopBar = hasTopBar + chromePaddingHolder.hasBottomBar = hasBottomBar + + let bodyPlaceable = subcompose(NavigationScaffoldSlot.mainContent) { + // Read inherited insets live so TabView chrome changes propagate. + let inherited = edgeInsets(from: EnvironmentValues.shared._contentWindowInsets) + let chromeTop = chromePaddingHolder.top + let chromeBottom = chromePaddingHolder.bottom + let chromePadding = PaddingValues(top: chromeTop, bottom: chromeBottom) + let contentInsets = contentWindowInsets( + top: inherited.top.dp + chromeTop, + leading: inherited.leading.dp, + bottom: inherited.bottom.dp + chromeBottom, + trailing: inherited.trailing.dp + ) + var contentSystemBarEdges = EnvironmentValues.shared._presentationSystemBarEdges + if chromePaddingHolder.hasTopBar { + contentSystemBarEdges.remove(.top) } - EnvironmentValues.shared.setValues { - $0.set_contentWindowInsets(contentInsets) - $0.set_presentationSystemBarEdges(contentSystemBarEdges) - $0.set_searchableState(searchableState) - $0.set_isNavigationRoot(arguments.isRoot) - $0.set_nestedScrollConnection(scrollBehavior.nestedScrollConnection) - return ComposeResult.ok - } in: { - // Elevate the top padding modifier so that content always has the same context, allowing it to avoid recomposition - Box(modifier: Modifier.padding(top: topPadding)) { - PreferenceValues.shared.collectPreferences([searchableStateCollector, scrollToTopCollector]) { - content(context.content()) - } - } + if chromePaddingHolder.hasBottomBar { + contentSystemBarEdges.remove(.bottom) } + renderMainContent(chromePadding, contentInsets, contentSystemBarEdges) + }.first().measure(looseConstraints) + layout(layoutWidth, layoutHeight) { + bodyPlaceable.place(x: 0, y: 0) + topBarPlaceable.place(x: 0, y: 0) + bottomBarPlaceable.place(x: 0, y: layoutHeight - bottomBarPlaceable.height) } - bottomBar() } } else { - // New Box layout (version >= 2) - Box(modifier: modifier.background(Color.background.colorImpl()).fillMaxSize()) { - // Top bar aligned to top - Box(modifier: Modifier.zIndex(Float(1.1)).align(androidx.compose.ui.Alignment.TopCenter)) { + let topBarHeightDp = with(density) { topBarHeightPx.value.toDp() } + let topPadding = arguments.ignoresSafeAreaEdges.contains(.top) ? max(topBarHeightDp, safeTopDp) : topBarHeightDp + let bottomPadding = bottomBarHeightPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.bottom) ? + safeBottomDp : with(density) { bottomBarHeightPx.value.toDp() } + let chromePadding = PaddingValues(top: topPadding, bottom: bottomPadding) + let contentInsets = contentWindowInsets(top: inheritedTop + topPadding, leading: inheritedLeading, bottom: inheritedBottom + bottomPadding, trailing: inheritedTrailing) + var contentSystemBarEdges = EnvironmentValues.shared._presentationSystemBarEdges + if showTopBar { + contentSystemBarEdges.remove(.top) + } + if bottomBarHeightPx.value > Float(0.0) { + contentSystemBarEdges.remove(.bottom) + } + + if layoutImplementationVersion < 2 { + // Old Column layout (version < 2) + Column(modifier: modifier.background(Color.background.colorImpl())) { + // Inset manually for any edge where our container ignored the safe area, but we aren't showing a bar + let layoutTopPadding = !showTopBar && arguments.ignoresSafeAreaEdges.contains(.top) ? safeTopDp : 0.dp + let layoutBottomPadding = bottomBarHeightPx.value <= Float(0.0) && arguments.ignoresSafeAreaEdges.contains(.bottom) ? bottomPadding : 0.dp + let consumePadding = PaddingValues(top: contentSystemBarEdges.contains(.top) ? 0.dp : topPadding, bottom: contentSystemBarEdges.contains(.bottom) ? 0.dp : bottomPadding) + let contentModifier = Modifier.fillMaxWidth().weight(Float(1.0)) + .padding(top: layoutTopPadding, bottom: layoutBottomPadding) + .consumeWindowInsets(consumePadding) + topBar() - } - - // Bottom bar aligned to bottom - Box(modifier: Modifier.zIndex(Float(1.1)).align(androidx.compose.ui.Alignment.BottomCenter)) { + Box(modifier: contentModifier, contentAlignment: androidx.compose.ui.Alignment.Center) { + var searchTopPadding = 0.dp + let searchableState: SearchableState? = arguments.isRoot ? (EnvironmentValues.shared._searchableState ?? searchableStatePreference.value.reduced) : nil + if let searchableState { + let searchFieldBackground = isSystemBackground ? Color.systemBarBackground.colorImpl() : androidx.compose.ui.graphics.Color.Transparent + let searchFieldFadeOffset = searchFieldHeightPx / 3 + let searchFieldModifier = Modifier.height(searchFieldHeight.dp + searchFieldPadding) + .align(androidx.compose.ui.Alignment.TopCenter) + .offset({ IntOffset(0, Int(searchFieldOffsetPx.value)) }) + .background(searchFieldBackground) + .padding(start: searchFieldPadding, bottom: searchFieldPadding, end: searchFieldPadding) + .graphicsLayer { alpha = max(Float(0.0), (searchFieldFadeOffset + searchFieldOffsetPx.value) / searchFieldFadeOffset) } + .fillMaxWidth() + SearchField(state: searchableState, context: context.content(modifier: searchFieldModifier)) + let searchFieldPlaceholderPadding = searchFieldHeight.dp + searchFieldPadding + (with(LocalDensity.current) { searchFieldOffsetPx.value.toDp() }) + searchTopPadding = searchFieldPlaceholderPadding + } + EnvironmentValues.shared.setValues { + $0.set_contentWindowInsets(contentInsets) + $0.set_presentationSystemBarEdges(contentSystemBarEdges) + $0.set_searchableState(searchableState) + $0.set_isNavigationRoot(arguments.isRoot) + $0.set_nestedScrollConnection(scrollBehavior.nestedScrollConnection) + return ComposeResult.ok + } in: { + Box(modifier: Modifier.padding(top: searchTopPadding)) { + PreferenceValues.shared.collectPreferences([searchableStateCollector, scrollToTopCollector]) { + content(context.content()) + } + } + } + } bottomBar() } - - // Constrain the content to the area between the top bar and bottom bar. In the Box layout we use - // fillMaxSize(), so we must add top/bottom padding to reserve space for our nav bars. Use the - // measured topBarHeightPx and bottomBarHeightPx when the bars are visible. When a bar is hidden, - // inset by the system safe area (WindowInsets.safeDrawing) for that edge when - // arguments.ignoresSafeAreaEdges contains it, so content does not overlap the status bar or home - // indicator. - let consumePadding = PaddingValues(top: contentSystemBarEdges.contains(.top) ? 0.dp : topPadding, bottom: contentSystemBarEdges.contains(.bottom) ? 0.dp : bottomPadding) - let contentModifier = Modifier.fillMaxSize().padding(chromePadding).consumeWindowInsets(consumePadding) - Box(modifier: contentModifier, contentAlignment: androidx.compose.ui.Alignment.Center) { - var topPadding = 0.dp - let searchableState: SearchableState? = arguments.isRoot ? (EnvironmentValues.shared._searchableState ?? searchableStatePreference.value.reduced) : nil - if let searchableState { - let searchFieldBackground = isSystemBackground ? Color.systemBarBackground.colorImpl() : androidx.compose.ui.graphics.Color.Transparent - let searchFieldFadeOffset = searchFieldHeightPx / 3 - let searchFieldModifier = Modifier.height(searchFieldHeight.dp + searchFieldPadding) - .align(androidx.compose.ui.Alignment.TopCenter) - .offset({ IntOffset(0, Int(searchFieldOffsetPx.value)) }) - .background(searchFieldBackground) - .padding(start: searchFieldPadding, bottom: searchFieldPadding, end: searchFieldPadding) - // Offset is negative. Fade out quickly as it scrolls in case it is moving up under transparent nav bar - .graphicsLayer { alpha = max(Float(0.0), (searchFieldFadeOffset + searchFieldOffsetPx.value) / searchFieldFadeOffset) } - .fillMaxWidth() - SearchField(state: searchableState, context: context.content(modifier: searchFieldModifier)) - let searchFieldPlaceholderPadding = searchFieldHeight.dp + searchFieldPadding + (with(LocalDensity.current) { searchFieldOffsetPx.value.toDp() }) - topPadding = searchFieldPlaceholderPadding + } else { + // Box layout (version 2) + Box(modifier: modifier.background(Color.background.colorImpl()).fillMaxSize()) { + Box(modifier: Modifier.zIndex(Float(1.1)).align(androidx.compose.ui.Alignment.TopCenter)) { + topBar() } - EnvironmentValues.shared.setValues { - $0.set_contentWindowInsets(contentInsets) - $0.set_presentationSystemBarEdges(contentSystemBarEdges) - $0.set_searchableState(searchableState) - $0.set_isNavigationRoot(arguments.isRoot) - $0.set_nestedScrollConnection(scrollBehavior.nestedScrollConnection) - return ComposeResult.ok - } in: { - // Elevate the top padding modifier so that content always has the same context, allowing it to avoid recomposition - Box(modifier: Modifier.padding(top: topPadding)) { - PreferenceValues.shared.collectPreferences([searchableStateCollector, scrollToTopCollector]) { - content(context.content()) - } - } + Box(modifier: Modifier.zIndex(Float(1.1)).align(androidx.compose.ui.Alignment.BottomCenter)) { + bottomBar() } + renderMainContent(chromePadding, contentInsets, contentSystemBarEdges) } } } @@ -753,6 +827,18 @@ public struct SkipNavigationStackPushKey : NavKey, Hashable { let toolbarPreferences: ToolbarPreferences } +/// Mutable chrome padding updated during SubcomposeLayout measure before body subcomposition (Scaffold pattern). +final class NavigationChromePaddingHolder { + var top: Dp = 0.dp + var bottom: Dp = 0.dp + var hasTopBar: Bool = false + var hasBottomBar: Bool = false +} + +private enum NavigationScaffoldSlot { + case topBar, bottomBar, mainContent +} + @Stable struct NavigationDestinationArguments: Equatable { let targetValue: Any } diff --git a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift index 951eaa84..f1065d64 100644 --- a/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift +++ b/Sources/SkipUI/SkipUI/Environment/EnvironmentValues.swift @@ -802,8 +802,8 @@ extension EnvironmentValues { /// Allow users to revert to previous layout behavior. var _layoutImplementationVersion: Int { - get { builtinValue(key: "_layoutImplementationVersion", defaultValue: { 2 }) as! Int } - set { setBuiltinValue(key: "_layoutImplementationVersion", value: newValue, defaultValue: { 2 }) } + get { builtinValue(key: "_layoutImplementationVersion", defaultValue: { 3 }) as! Int } + set { setBuiltinValue(key: "_layoutImplementationVersion", value: newValue, defaultValue: { 3 }) } } var _lineLimitReservesSpace: Bool? {