Skip to content

.ignoreSafeArea() doesn't work correctly with exitUntilCollapsedScrollBehavior #295

@dfabulich

Description

@dfabulich
import SwiftUI
#if SKIP
import androidx.compose.material3.TopAppBarDefaults
#endif

// SKIP INSERT: @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
struct ContentView: View {
    
    var body: some View {
        NavigationStack {
            Greeting()
            .navigationTitle("test1")
            .navigationBarTitleDisplayMode(.inline)
        }
        #if SKIP
        .material3TopAppBar { options in
            return options.copy(
                scrollBehavior: TopAppBarDefaults.exitUntilCollapsedScrollBehavior(),
                colors: TopAppBarDefaults.topAppBarColors(
                    containerColor = androidx.compose.ui.graphics.Color.Transparent,
                    scrolledContainerColor = androidx.compose.ui.graphics.Color.Transparent
                )
            )
        }
        #endif
    }
}

struct Greeting: View {
    var body: some View {
        GeometryReader { geometry in
            ScrollView(.vertical) {
                VStack {
                    ForEach(0..<12) { i in
                        Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
                            .padding()
                    }
                }
                .padding(geometry.safeAreaInsets)
            }
            .ignoresSafeArea()
        }
    }   
}

In this example code, we're setting exitUntilCollapsedScrollBehavior on the top app bar, which will make the top app bar scroll away as you scroll the screen. To prevent layout shift / judder as we scroll, we're using .ignoresSafeArea(), which allows the content to render underneath the top app bar.

But, to my surprise, when I add modifier: modifier.onGloballyPositionedInWindow in ContentLayouts.swift, like this:

Diff of logging changes

diff --git c/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift w/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift
index ec0c980..f12b230 100644
--- c/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift
+++ w/Sources/SkipUI/SkipUI/Compose/ComposeLayouts.swift
@@ -1,6 +1,7 @@
 // Copyright 2023–2025 Skip
 // SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
 #if SKIP
+import android.util.Log
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.requiredHeight
 import androidx.compose.foundation.layout.requiredWidth
@@ -120,16 +121,23 @@ private func flexibleLayoutFloat(_ value: CGFloat?) -> Float? {
 /// - Parameter checkEdges: Which edges to check to see if we're against a safe area. Any matching edges will be
 ///     passed to the given closure.
 @Composable func IgnoresSafeAreaLayout(expandInto: Edge.Set, checkEdges: Edge.Set = [], modifier: Modifier = Modifier, target: @Composable (IntRect, Edge.Set) -> Void) {
+    // Generate a unique ID for this layout instance that persists across recompositions
+    let layoutId = remember {
+        staticLayoutIdCounter += 1
+        return staticLayoutIdCounter
+    }
+
     guard let safeArea = EnvironmentValues.shared._safeArea else {
         target(IntRect.Zero, [])
         return
     }
-
+
     // Note: We only allow edges we're interested in to affect our internal state and output. This is critical
     // for reducing recompositions, especially during e.g. navigation animations. We also match our internal
     // 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
+    Log.d("IgnoresSafeAreaLayout", "[ID:\(layoutId)] edges - \(edges)")
     var expansionTop = 0
     if expandInto.contains(Edge.Set.top) && edges.contains(Edge.Set.top) {
         expansionTop = Int(safeArea.safeBoundsPx.top - safeArea.presentationBoundsPx.top)
@@ -170,6 +178,7 @@ private func flexibleLayoutFloat(_ value: CGFloat?) -> Float? {
         return ComposeResult.ok
     } in: {
         Layout(modifier: modifier.onGloballyPositionedInWindow {
+            Log.d("IgnoresSafeAreaLayout", "[ID:\(layoutId)] onGloballyPositionedInWindow - bounds: (top=\($0.top), left=\($0.left), bottom=\($0.bottom), right=\($0.right), width=\($0.width), height=\($0.height))")
             let edges = adjacentSafeAreaEdges(bounds: $0, safeArea: safeArea, isRTL: isRTL, checkEdges: expandInto.union(checkEdges))
             edgesState.value = edges
         }, content: {
@@ -276,6 +285,9 @@ private func adjacentSafeAreaEdges(bounds: Rect, safeArea: SafeArea, isRTL: Bool
     }
 }

+// Static counter for generating unique layout IDs
+private var staticLayoutIdCounter = 0
+
 private func constraint(_ value: Int, subtracting: Int) -> Int {
     guard value != Int.MAX_VALUE else {
         return value

I find that the resolved height of the layout fluctuates. I expected its resolved hight to always be 2400, the height of my emulator's screen.

Example log

2026-01-11 20:12:08.624  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=1.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2399.0)
2026-01-11 20:12:08.646  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=15.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2385.0)
2026-01-11 20:12:08.666  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=20.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2380.0)
2026-01-11 20:12:08.680  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=22.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2378.0)
2026-01-11 20:12:08.701  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=19.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2381.0)
2026-01-11 20:12:08.720  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=17.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2383.0)
2026-01-11 20:12:08.736  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=16.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2384.0)
2026-01-11 20:12:08.750  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=11.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2389.0)
2026-01-11 20:12:08.764  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=10.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2390.0)
2026-01-11 20:12:08.781  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=5.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2395.0)
2026-01-11 20:12:08.796  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=8.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2392.0)
2026-01-11 20:12:08.812  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=4.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2396.0)
2026-01-11 20:12:08.828  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=7.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2393.0)
2026-01-11 20:12:08.845  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=6.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2394.0)
2026-01-11 20:12:08.861  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=7.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2393.0)
2026-01-11 20:12:08.876  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=0.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2400.0)
2026-01-11 20:12:08.891  7596-7596  IgnoresSafeAreaLayout   org.appfair.app.ShowcaseLite         D  [ID:3] onGloballyPositionedInWindow - bounds: (top=0.0, left=0.0, bottom=2400.0, right=1080.0, width=1080.0, height=2400.0)

The result is a slight layout shift / judder as you scroll the top app bar into and out of the viewport.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcomposeLimitation of Jetpack Compose or issue with SwiftUI translationlayoutSwiftUI/Jetpack Compose layout issues

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions