Skip to content

Conversation

@dfabulich
Copy link
Contributor

Fixes #295

This PR looks small, but it feels like major surgery in the very heart of the NavigationStack, converting it from a Column containing the topBar, content, and bottomBar into a Box where the topBar is in its own Box aligned at the top, the bottomBar is aligned at the bottom, and the content appears in its own Box, filled to max size, relying on safe area insets to avoid clobbering the top and bottom bars.

(We only need to add top/bottom padding if the embedded content ignores the safe area at that edge; in that case, we add padding just so that IgnoresSafeAreaLayout will undo the padding we just added.)

It's quite difficult for me to be confident that I didn't break any layout. I did verify that everything's working in the Safe Area playground in the Showcase. There appear to be no tests in Tests that test ignoresSafeArea. (But, just to be safe, I ran 'em all.)

FWIW, I suspect that this code may improve performance in NavigationStack. In my repro for #295, this PR fixing the layout shift/judder during scroll naturally resulted in fewer recompositions happening during scroll.

(Perhaps I'll take the liberty of calling for @aabewhite's attention on this one!)

Skip Pull Request Checklist:

  • REQUIRED: I have signed the Contributor Agreement
  • REQUIRED: I have tested my change locally with swift test
  • OPTIONAL: I have tested my change on an iOS simulator or device
  • OPTIONAL: I have tested my change on an Android emulator or device

@cla-bot cla-bot bot added the cla-signed label Jan 12, 2026
@marcprux marcprux requested a review from aabewhite January 12, 2026 15:33
@marcprux marcprux added compose Limitation of Jetpack Compose or issue with SwiftUI translation layout SwiftUI/Jetpack Compose layout issues navigation Issues with navigation behavior parity between SwiftUI and Jetpack Compose labels Jan 12, 2026
@marcprux
Copy link
Member

Agreed, we'll need to look pretty closely at this. Did you run through all the navigation playgrounds in Showcase as well?

In the past with risky overhauls like this (like #238), we've offered a backwards-compatibility setting as an escape hatch. I wonder if you could re-use the .layoutImplementationVersion() and bump the default value to 2, and only use the new navigation layout if it is higher than that. It might be a lot of refactoring (or a lot of code duplication), but it would ease our minds about providing a way to avoid large-scale breakage.

/// Allow users to revert to previous layout behavior.
var _layoutImplementationVersion: Int {
get { builtinValue(key: "_layoutImplementationVersion", defaultValue: { 1 }) as! Int }
set { setBuiltinValue(key: "_layoutImplementationVersion", value: newValue, defaultValue: { 1 }) }
}

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())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing the WindowInsets.safeDrawing and WindowInsets.ime insets seems wrong. Don't we need them to accommodate the keyboard at least?

Copy link
Contributor Author

@dfabulich dfabulich Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, you'd think so, wouldn't you? But I tried my best to repro any problem with removing it, and I failed to do so.

For example, I went to the TextField playground, scrolled to a text field in the bottom half of the screen, and tapped on it. The software keyboard IME appeared, and the text field scrolled up into the view, same as before.

If you read closely here, the logic of using the WindowInsets.safeDrawing was quite strange. The code preferred to use topBarBottomPx and bottomBarTopPx (computed by us). We trust WindowInsets.safeDrawing only when we're ignoring the safe area at that edge, and only when topBarBottomPx and bottomBarTopPx are both 0. I couldn't figure out why the implementation looks that way.

That means we'd completely ignore WindowInsets.ime whenever we're not ignoring the safe area, which is 99% of the Showcase app. Even when we do ignore the safe area, we ignore WindowInsets.ime whenever we're showing a bottom bar.

I don't think the WindowInsets.ime code path was being used anywhere at all in the Showcase app.

Note that the whole point of fixing my judder bug #295 was to have a single source of truth as to the size of the safe area that IgnoresSafeAreaLayout had to expand beyond. The bug was that sometimes the Navigation Stack would position us based on the current scrolling height of the top bar, and sometimes it would wait until the top bar's onGloballyPositioned modifier had triggerred, which would update state and eventually cause a recomposition.

If we can get away without explicitly inspecting WindowInsets here in weird code paths. (and it seems that we can, unless we can find a counterexample), I think we should avoid it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember this code perfectly, but I do have some idea of what it does. The scenario is:

  • The Navigation container itself ignores top and bottom safe areas caused by system chrome/bars. There's a comment ~ line 146 that this is needed to prevent push/pop animation issues.
  • If the NavigationStack is configured to hide its top and/or bottom bars, topBarBotomPx etc will be 0 and the content wouldn't normally get inset at all.
  • But given that our container is ignoring system bars, this means the content would layout in the system bar area.

So this code applies the insets needed to avoid the system bars (the top safe drawing area and the bottom safe drawing area over any keyboard - we don't need to include the keyboard because the whole app container insets for the keyboard).

I believe you will still need code like this to handle the bars-hidden case. If you look in the Toolbar playground and select the sub-playground to hide the top bar, you can see that the content encroaches into the status bar area.

@dfabulich dfabulich force-pushed the fix-ignore-safe-area-navigation-stack-layout-shift branch from 9696df4 to 12e197d Compare January 12, 2026 23:57
@dfabulich
Copy link
Contributor Author

The latest version of this PR now adds a layoutImplementationVersion 2. I've tested that .layoutImplementationVersion(1) reverts back to the Column-based layout. (I copied and pasted a bunch of code rather than trying to deduplicate, because I figure anybody who wants to opt in to the old algorithm wants it exactly the way it used to be. If/when we make further changes to the Box-based layout, those can and should diverge from the old way.)

I also added in a layout fix which arose when I tested the navigation playgrounds. 😬 Good on us for finding it! But IMO that's also a bad sign… where else do I still need to check??

Copy link
Contributor

@aabewhite aabewhite left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really appreciate the patch! See my two comments. So action items are:

  1. Restore the safeDrawing insets for cases when the bars are hidden.
  2. Simplify the new Box code to pad the content Box based on the heights of the top and bottom bars.

Hopefully I'm correct in how this is all working and not leading you in the wrong direction.

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())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember this code perfectly, but I do have some idea of what it does. The scenario is:

  • The Navigation container itself ignores top and bottom safe areas caused by system chrome/bars. There's a comment ~ line 146 that this is needed to prevent push/pop animation issues.
  • If the NavigationStack is configured to hide its top and/or bottom bars, topBarBotomPx etc will be 0 and the content wouldn't normally get inset at all.
  • But given that our container is ignoring system bars, this means the content would layout in the system bar area.

So this code applies the insets needed to avoid the system bars (the top safe drawing area and the bottom safe drawing area over any keyboard - we don't need to include the keyboard because the whole app container insets for the keyboard).

I believe you will still need code like this to handle the bars-hidden case. If you look in the Toolbar playground and select the sub-playground to hide the top bar, you can see that the content encroaches into the status bar area.

@dfabulich dfabulich force-pushed the fix-ignore-safe-area-navigation-stack-layout-shift branch from f046909 to 3b26a66 Compare January 19, 2026 23:37
@dfabulich dfabulich force-pushed the fix-ignore-safe-area-navigation-stack-layout-shift branch from 3b26a66 to f0f7d71 Compare January 20, 2026 05:44
@dfabulich
Copy link
Contributor Author

OK! I've tested the current version against the NavigationStack playground, the Toolbar playground, and the SafeArea playground, including a new Toolbar subplayground skiptools/skipapp-showcase#56 and a new SafeArea subplayground skiptools/skipapp-showcase#57 that I used to repro the issues that @aabewhite reported.

I think this should be clear to merge now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed compose Limitation of Jetpack Compose or issue with SwiftUI translation layout SwiftUI/Jetpack Compose layout issues navigation Issues with navigation behavior parity between SwiftUI and Jetpack Compose

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.ignoreSafeArea() doesn't work correctly with exitUntilCollapsedScrollBehavior

3 participants