Skip to content

feat: add corner-adapted safe area insets - #792

Merged
janicduplessis merged 6 commits into
mainfrom
@janic/corner-adapted-insets
Oct 21, 2026
Merged

janicduplessis merged 6 commits into
mainfrom
@janic/corner-adapted-insets

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

On the iPhone Duo cover display (iOS 27.1, folded), the status bar moves into a column on the right edge, so the window's safe area has top = 0. The display still has rounded corners. A header laid out from the regular insets runs under the corners and its buttons and title get clipped. UIKit's own bars avoid this with corner adaptation: iOS 26 added edgeInsets(for: .safeArea(cornerAdaptation:)), which returns the safe area enlarged where the corners reach past it. On the folded Duo the window's vertical value has top = 17.33.

This PR exposes those corner-adapted insets as an opt-in. Defaults are unchanged: useSafeAreaInsets(), SafeAreaView and initialWindowMetrics.insets still return the regular insets.

const insets = useSafeAreaInsets({ cornerAdaptation: 'vertical' }); // or 'horizontal'

<SafeAreaView edges={['top', 'left', 'right']} cornerAdaptation="vertical" />

Reviewer notes:

  • The Android port of UIKit's rule has not run on a device with rounded corners. Stock emulators report radius 0, and I could not get a radius override onto the emulator (see Test Plan). The rule itself reproduces every UIKit sample (below).
  • horizontal is not a no-op on regular iPhones: UIKit returns left 18, right 18 on an iPhone 18 Pro, where the regular side insets are 0.
  • The default compileSdkVersion fallback in android/build.gradle goes from 30 to 31, for RoundedCorner.
  • Builds on feat: respect window insets a parent adjusts for its subtree on Android #791: on Android the regular insets come from the window insets dispatched to the provider, and the corner adaptation is applied on top of them. Rounded corners are still read from the root window insets, because they describe the display and a parent that rebuilds the insets it dispatches may not carry them over.

API details:

  • vertical enlarges the top and bottom insets, horizontal the left and right. The names follow UIKit's cornerAdaptation label and .vertical / .horizontal axes.
  • The provider measures both variants natively with the regular insets (two more UIKit reads per update on iOS). The event and initialWindowMetrics carry verticalCornerAdaptedInsets and horizontalCornerAdaptedInsets, so the first frame is synchronous.
  • The variants are only recomputed when their inputs change: on Android the regular insets, the frame and the window's RoundedCorners; on iOS the regular insets, the frame in the window and the window frame. iOS layout passes always measure again, because UIKit updates the values while the Duo switches displays before any of those inputs change.
  • Both variants live in one new context, SafeAreaCornerAdaptedInsetsContext, as { vertical, horizontal }. SafeAreaInsetsContext is unchanged: still a plain insets object, so spreading it, mocks and hand-made providers keep working. Without the option the hook reads only SafeAreaInsetsContext, so a change in only the corner-adapted values does not re-render plain consumers. With the option it reads the new context and falls back to the regular insets when it is missing, e.g. under a hand-made SafeAreaInsetsContext.Provider.
  • There is no option for both axes: UIKit's UIViewLayoutRegionAdaptivityAxis is a single choice of none, horizontal or vertical. Apps can combine the two results per edge.
  • SafeAreaView selects the variant natively and, like the regular insets, reads it from the nearest provider, so it matches the hook. Measuring on the view itself would not work well: UIKit's value depends on the receiver's height (below), so the inset would depend on the padding it produces.
  • A provider whose native side does not send the variants (web, Windows, older native code) falls back to the regular insets, and so does initialMetrics without them.

Platform behavior

  • iOS 26+: UIView.edgeInsetsForLayoutRegion: with safeAreaLayoutRegionWithCornerAdaptation:, guarded by the iOS/tvOS 26 SDK and @available(iOS 26.0, tvOS 26.0, visionOS 26.0, *), so the library still builds with older Xcode versions. Older iOS returns the regular insets.
  • Android 12+ (API 31): UIKit's rule (below) applied to each WindowInsets.getRoundedCorner() radius, in px, rounded to whole px. Below API 31 the variants equal the regular insets.
  • Web: the regular insets.

UIKit's rule

I sampled edgeInsets(for: .safeArea(cornerAdaptation:)) on 206 views per device (strips of varying height and width at each edge, offsets from 0 to 70 pt, corner boxes, mixed offsets) on an iPhone 18 Pro (iOS 27.0), an iPad Pro 11-inch M5 (iOS 27.0) and the Duo folded and unfolded (iOS 27.1). This rule reproduces all 1,648 values on every device:

for each display corner with radius R:
  r     = min(R - max(dx, dy), width / 2, height / 2)   // dx, dy: receiver's distance from the two edges at that corner
  inset = roundToPixel(max(r, 0) * (1 - √2/2))          // the arc's 45° point
vertical:   top    = max(regular.top,    inset(top-left),    inset(top-right))
            bottom = max(regular.bottom, inset(bottom-left), inset(bottom-right))
horizontal: left   = max(regular.left,   inset(top-left),    inset(bottom-left))
            right  = max(regular.right,  inset(top-right),   inset(bottom-right))

The regular side inset doesn't factor in: on the folded Duo the top-right corner applies even though it sits in the 84 pt status bar column.

Device Display radius (_displayCornerRadius) Radius that fits the data
iPhone 18 Pro 62 62 (all corners)
iPad Pro 11-inch (M5) 30 31.6 to 32.5
Duo folded (466x678) 59 59 on the right, about 8 on the hinge side
Duo unfolded (951x669) 59 55

So _displayCornerRadius is not always the radius UIKit uses (iPad, unfolded Duo, the Duo hinge side). Android reads the per-corner radius from getRoundedCorner(), which plays that role. A Python port of the Kotlin implementation, fed the fitted radii, also matches all 1,648 samples on each device.

Sample values (all match the rule)
Device Receiver Edge Regular Corner-adapted
Duo folded window vertical top 0 17.33
Duo folded top strip, height 20 vertical top 0 3.00
Duo folded top strip, height 60 vertical top 0 8.67
Duo folded top strip, height 120 vertical top 0 17.33
Duo folded y 10, height 200 vertical top 0 14.33
Duo folded window horizontal left 0 2.33
Duo unfolded window vertical top 0 16.00
Duo unfolded window horizontal left 0 16.00
Duo unfolded (40, 8) 100x100 vertical top 0 4.33
iPhone 18 Pro window horizontal left 0 18.00
iPhone 18 Pro left strip, width 20 horizontal left 0 3.00
iPhone 18 Pro left strip, width 60 horizontal left 0 8.67
iPhone 18 Pro x 10, width 200 horizontal left 0 15.33
iPhone 18 Pro x 40, width 200 horizontal left 0 6.33
iPhone 18 Pro window vertical top 62 62
iPad Pro 11" window horizontal left 0 9.50
iPad Pro 11" left strip, width 20 horizontal left 0 3.00
iPad Pro 11" x 10, width 200 horizontal left 0 6.50

Related: #321 asked for getRoundedCorner values on Android.

Test Plan

Unit tests cover the hook option, the fallback when metrics have no variants, updates from the native event, nested providers, the cornerAdaptation prop reaching the native view, and that a plain consumer does not re-render when only a variant changes.

The example app has a new "Corner Adaptation" screen (dev menu): a header SafeAreaView whose cornerAdaptation cycles through vertical, horizontal and none, and the three inset sets from the hook and initialWindowMetrics. It renders in a full screen Modal because react-native-test-app hosts the app in a UINavigationController, whose safe area already reports top 24 on the Duo cover.

Duo folded, no adaptation Duo folded, vertical iPhone 18 Pro, vertical
Duo unfolded, vertical

Simulator screenshots don't draw the display's corner mask, so the clipping itself isn't visible; the header moves down by the 17.33 pt inset.

  • iPhone Duo, iOS 27.1, Xcode 27.1, folded: regular t 0, r 84, b 34; vertical t 17.33; horizontal l 2.33; initialWindowMetrics has the same variants. Unfolding updates the provider live to vertical t 16 and horizontal l 16, matching the UIKit probe.
  • iPhone 18 Pro, iOS 27.0: regular t 62, b 34; vertical equal; horizontal l 18, r 18.
  • iPhone 16 Pro, iOS 18.6: all three equal (t 62, b 34), the pre-iOS 26 fallback.
  • Android, Pixel 9 emulator on API 34: builds and runs, and the variants equal the regular insets because the emulator reports radius 0. I tried to give it rounded corners to compare against iOS, without success:
    • cmd overlay fabricate on a rooted Google APIs image only accepts integer resource types, so it can't set android:dimen/rounded_corner_radius.
    • A runtime resource overlay APK setting rounded_corner_radius is refused at install: an overlay targeting android must be signed with the platform key or name an overlayable, and this resource isn't in one.
    • Installing that APK as a static overlay in /product/overlay needs a -writable-system emulator. It never finished booting on this heavily loaded machine (adb shell stayed unresponsive for over 40 minutes, twice).
      The Android numbers still need a check on hardware with rounded corners, e.g. any Pixel.

The device runs use the new architecture. The iOS Paper files are compiled in that build but not exercised; the Android src/paper sources were not compiled.

@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
react-native-safe-area-context Ignored Ignored Preview Oct 21, 2026 4:29pm UTC

Request Review

Add a cornerAdaptation option to useSafeAreaInsets and a cornerAdaptation prop to SafeAreaView. Providers measure the vertical and horizontal corner-adapted insets natively with the regular insets, and initialWindowMetrics includes them. iOS 26+ uses the UIKit layout region API, Android 12+ approximates from the window's rounded corners, and other platforms return the regular insets. The default insets are unchanged.
@janicduplessis
janicduplessis force-pushed the @janic/corner-adapted-insets branch from 8f96de3 to 9ed2236 Compare October 21, 2026 04:23
@janicduplessis
janicduplessis marked this pull request as ready for review October 21, 2026 18:29
@janicduplessis
janicduplessis merged commit afb40f2 into main Oct 21, 2026
10 of 14 checks passed
@janicduplessis
janicduplessis deleted the @janic/corner-adapted-insets branch October 21, 2026 20:03
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.

1 participant