Skip to content

Fix iOS WindowInsets over-reported on non-fullscreen / safe-area-filt…#3062

Open
Jacob Rhoda (jadar) wants to merge 1 commit into
JetBrains:jb-mainfrom
jadar:cmp-10230-ios-host-view-insets
Open

Fix iOS WindowInsets over-reported on non-fullscreen / safe-area-filt…#3062
Jacob Rhoda (jadar) wants to merge 1 commit into
JetBrains:jb-mainfrom
jadar:cmp-10230-ios-host-view-insets

Conversation

@jadar

Copy link
Copy Markdown

UIKitWindowInsetsManager per-edge union()s safe-area insets from the local _overlayView with those from window.rootViewController.view. This was introduced in #2946 to cover CMP-9931 and CMP-8922 but causes over-reporting whenever the Compose surface is positioned below window-level chrome that does not intersect _overlayView, or whose ancestor chain filters safe-area propagation (UIHostingController.safeAreaRegions = .keyboard, parent additionalSafeAreaInsets, manual frame placement). In those configurations the local view correctly reports zero for the affected edge but the window root reports the global value, and max() wins.

This MR:

  1. Removes the window.rootViewController.view source so insets come only from the immediate hosting view. Standard UIKit safe-area propagation already incorporates ancestor chrome (status bar, navigation bar, tab bar) into a child view's safeAreaInsets for typical hosting setups, so the navbar case from CMP-9931 should still be covered. Worth re-validating against the original CMP-9931 reproduction.

  2. Adds a dedicated onSafeAreaInsetsDidChange callback path so Compose re-reads insets the moment iOS reports a change, rather than via a setNeedsLayout indirection that can be deferred. Two parallel entry points land at the same mediator.onSafeAreaInsetsDidChange():

    • View-level: a new onSafeAreaInsetsDidChange constructor parameter on BackgroundInputView, invoked from its safeAreaInsetsDidChange override.
    • VC-level: an Obj-C bridge on CMPViewController (matches the existing userInterfaceStyleDidChange pattern), declared safeAreaInsetsDidUpdate and overridden in ComposeHostingViewController to forward through the container.

    Both paths are defense-in-depth. Each is a no-op when the value hasn't changed (mutableStateOf only triggers recompose on actual change).

The existing WindowInsetsPaddingTest.testWindowInsetsPaddingAppliedToNonFullscreenComposeUIViewContent encoded the union semantics; it has been renamed and inverted to assert the new contract. The fullscreen-outer test is unchanged.

Known limitation not addressed: iOS does not deliver safeAreaInsetsDidChange (UIView) or viewSafeAreaInsetsDidChange (UIViewController) to a view/VC occluded by a presented sheet, including during interactive sheet dismissal ("peek"). Cached safeAreaInsets persist until the view is fully visible again. Confirmed empirically with both hooks wired. Per-frame CADisplayLink polling does not help (returns the same stale value); geometric inset computation from window-space position handles peek but over-subtracts the legitimate navbar contribution in standard UINavigationController setups. Documenting as a known UIKit quirk.

Alternative considered: rather than removing the window-root source, expose it as a ComposeUIViewControllerConfiguration.windowInsetsStrategy flag with values HostingViewOnly and UnionWithWindow, defaulting to the latter to preserve current behavior. Happy to take that route if maintainers prefer for backwards-compat.

Fixes CMP-10230.

Testing

  • WindowInsetsPaddingTest runs green on iOS simulator.
  • Manual reproduction: ComposeUIViewController embedded under a UIHostingController with safeAreaRegions = .keyboard; content is no longer double-padded.
  • Manual reproduction: parent layout shift (in-app banner shown/hidden above CMP) triggers Compose recomposition with the new insets without requiring a full appearance transition.
  • Should be re-validated by QA: CMP-9931 (ComposeUIViewController pushed onto UINavigationController, navbar height accounted for) and CMP-8922 (iPad Stage Manager traffic-light controls).

This should be tested by QA.

Release Notes

Fixes - iOS

  • Fix WindowInsets over-reported on Compose surfaces positioned below window-level chrome that does not intersect the host view (e.g. under a UIHostingController with safeAreaRegions = .keyboard). Fixes CMP-10230.

@google-cla

google-cla Bot commented May 23, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@mazunin-v-jb

Vladimir Mazunin (mazunin-v-jb) commented May 25, 2026

Copy link
Copy Markdown

Hello, Jacob Rhoda (@jadar)!
It looks like that you're required to sign CLA for your email and resubmit this commit using your email to pass the CLA check.

@jadar

Copy link
Copy Markdown
Author

Vladimir Mazunin (@mazunin-v-jb) I signed it and re-scanned multiple times to no avail. 🤷‍♂️

…ered surfaces

Fixes [CMP-10230](https://youtrack.jetbrains.com/issue/CMP-10230)

`UIKitWindowInsetsManager` per-edge `union()`s safe-area insets from the
local `_overlayView` with those from `window.rootViewController.view`.
This was added in JetBrains#2946 to cover CMP-9931 (missing `UINavigationBar`
height) and CMP-8922 (iPad Stage Manager traffic-light controls), but
over-reports whenever the Compose surface is positioned below window-
level chrome that does not intersect `_overlayView`, or whose ancestor
chain filters safe-area propagation (`UIHostingController.safeAreaRegions
= .keyboard`, parent `additionalSafeAreaInsets`, manual frame placement).
The local view correctly reports zero for the affected edge, but the
window root reports the global value and `max()` wins.

This commit:

1. Removes the `window.rootViewController.view` source from
   `windowInsetsViews`. Standard UIKit safe-area propagation already
   incorporates ancestor chrome into a child view's `safeAreaInsets`
   for typical hosting setups, so the CMP-9931 navbar case should
   still be covered. Worth re-validating against the original repro.

2. Adds a dedicated `onSafeAreaInsetsDidChange` callback on
   `BackgroundInputView` (parallel to the existing `onLayoutSubviews`)
   wired to a new `internal fun onSafeAreaInsetsDidChange()` on the
   mediator. The callback is invoked directly from
   `safeAreaInsetsDidChange`, without going through `setNeedsLayout`,
   so Compose re-reads insets even when the parent reposition leaves
   the input view's local frame unchanged.

3. Adds an Obj-C bridge on `CMPViewController` to expose
   `viewSafeAreaInsetsDidChange` for Kotlin override (matches the
   existing `userInterfaceStyleDidChange` pattern). The new
   `safeAreaInsetsDidUpdate` hook is implemented in
   `ComposeHostingViewController` and forwards through
   `container.onSafeAreaInsetsDidChange()`. This is defense-in-depth
   alongside the view-level hook. Each path is a no-op when the value
   hasn't changed (`mutableStateOf` only triggers recompose on actual
   change), so redundant delivery is cheap.

The existing `WindowInsetsPaddingTest.testWindowInsetsPaddingAppliedTo
NonFullscreenComposeUIViewContent` encoded the union semantics; it has
been renamed and inverted to assert the new contract (no padding on a
non-fullscreen embedded `ComposeUIView`). The fullscreen-outer test is
unchanged.

Known limitation not addressed by this commit: iOS does not deliver
`safeAreaInsetsDidChange` (UIView) or `viewSafeAreaInsetsDidChange`
(UIViewController) to a view/VC occluded by a presented sheet,
including during interactive sheet dismissal ("peek"). Confirmed
empirically with both hooks wired. Per-frame `CADisplayLink` polling
does not help because the read returns the same stale value iOS is
caching; geometric inset computation from window-space position
handles peek but over-subtracts the legitimate navbar contribution in
standard `UINavigationController` setups.

## Release Notes
### Fixes - iOS
- Fix `WindowInsets` over-reported on Compose surfaces positioned below
  window-level chrome that does not intersect the host view (e.g. under
  a `UIHostingController` with `safeAreaRegions = .keyboard`).
@jadar Jacob Rhoda (jadar) force-pushed the cmp-10230-ios-host-view-insets branch from 06b71f8 to 2b87d60 Compare May 25, 2026 20:14
@jadar

Copy link
Copy Markdown
Author

Vladimir Mazunin (@mazunin-v-jb) looks like it went through!

// WindowInsets.statusBars should only represent the insets at the top in portrait orientation
val topSafeAreaInsetsDp = viewController.view.safeAreaInsets.useContents { top }.dp

assertEquals(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I think this behavior was added intentionally because it mimics the Android one.
Please, correct me Vendula Švastalová (@svastven) .

Choose a reason for hiding this comment

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

Yes, that is right. I have added a comment to the task reporting this issue.

container.updateUserInterfaceStyle(traitCollection.userInterfaceStyle)
}

override fun safeAreaInsetsDidUpdate() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You already added the observer to safe area insets in the BackgroundInputView. Why another one is needed here?


// Insets are read from the host view only. Adding `window.rootViewController.view`
// (the union introduced in #2946 for CMP-9931) over-reports when CMP is positioned
// below window-level chrome that does not intersect `_overlayView`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I would avoid such comments - they are related to the change, not to the resulting code.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants