Skip to content

Add useBarRegion for the iOS 27.1 vertical bar layout region - #43

Draft
janicduplessis wants to merge 1 commit into
mainfrom
@janic/bar-region
Draft

janicduplessis wants to merge 1 commit into
mainfrom
@janic/bar-region

Conversation

@janicduplessis

Copy link
Copy Markdown
Contributor

Motivation

On iPhone Duo (iOS 27.1) the system shows status in a vertical column on the trailing edge. Native bars put controls in that column: the back button and tab bar items are placed from UIKit's bar layout region (UIView.LayoutRegion.bar(onEdge:extent:), ObjC layoutRegionForBarOnDirectionalEdge:extent:). React Native apps that want the same thing, such as a header back button or a map's location button, have no way to find that spot.

The occlusion regions this library already reports don't answer it. They only say where content is hidden (the camera and the status area at the top of the column). The bar region says where controls go: it starts below the status area, is centered on the status icons, and depends on the size of the content placed in it.

Measured on the Duo simulator, iOS 27.1, full-screen provider:

Posture Occlusions Bar region, extent 44 Bar region, extent 48
Folded camera 399.7, 29.3, 37×37, status area 382, 0, 84×174 396, 174, 44×470 394, 174, 48×470
Unfolded status area 867, 0, 84×124 881, 124, 44×511 879, 124, 48×511

API

const bar = useBarRegion({ extent: 44 });
// null, or { edge: 'leading' | 'trailing', frame: { x, y, width, height } }

The frame is relative to the nearest ReservedRegionsProvider, unclipped, like the other regions. It returns null when no vertical bar overlaps the provider, on Android, when built with an SDK older than iOS 27.1 (compiled out with the same __IPHONE_27_1 guard as the existing calls) and on iOS older than 27.1.

I kept the name useBarRegion. It follows UIKit's LayoutRegion.bar, and it is not a reserved region kind, so it stays out of useReservedRegions().

Native design

The region depends on the extent, so each hook registers its extent with the provider (ref-counted, so hooks asking for the same extent share one query). The provider passes the list to the native view as a barExtents prop, and the native view measures every extent in the same layoutSubviews pass as divisions and occlusions and sends them in the same regionsChange event (bars: [{ extent, edge, frame }], edge: 'none' when there is no bar). During a fold, regions and bars therefore always come from the same layout pass and arrive together.

I ruled out the other two shapes:

  • A native method taking an extent: Fabric view commands can't return a value, and a synchronous TurboModule can't read UIKit layout off the main thread, so the result would be async and could lag a fold.
  • Reporting the bar edge and computing the frame in JS: the placement is UIKit's (centering on the status icons, starting below the status area), and copying it in JS would be a guess that can drift between iOS versions.

Re-queries happen on layout, hinge updates (existing), changes to UITraitCollection.systemTraitsAffectingVerticalBarEdge (new, via registerForTraitChanges), and when the extent list changes. UIKit posts no notification for the region itself.

Window, not provider. The native view queries the bar region on its window and converts it to provider coordinates. Querying on the provider itself is wrong whenever the provider doesn't line up with the window, because UIKit computes a view's bar region from that view's own bounds and safe area. Folded Duo, extent 44, both results converted to window coordinates:

Provider (window frame) Window query Provider query
Full screen 0, 0, 466×678 396, 174, 44×470 396, 174, 44×470
Shift 40 40, 0, 466×678 396, 174, 44×470 378, 17.3, 44×626.7
Inset 24 24, 24, 418×554 396, 174, 44×470 372, 174, 44×404
Content box 24, 180, 418×398 396, 174, 44×470 372, 180, 44×398

That is the same mismatch th3rdwave saw on a nested tab screen (x = 254). The bar is reported when the window's region overlaps the provider, the same rule reservedRegionsOfKind: uses.

Readiness. A new extent is measured one layout pass after the hook first requests it, so the first render returns null even on a Duo. useReservedRegionsReady and ReservedRegionsGate are unchanged and don't wait for bar regions. This is documented in the API reference.

Android needs no native change: the view manager ignores the unknown barExtents prop, and a missing bars field reads as no bar.

Verification

Example app: a teal overlay for the 44-point region, a round 44-point button placed in it that counts taps, and text lines for the 44 and 48 extents.

Duo folded, Full screen Duo folded, Shift 40 Duo folded, Inset 24 Duo folded, Content box
Duo unfolded iPhone 18 Pro, iOS 27.0 Android 7.6in Foldable, API 34
  • iPhone Duo simulator, iOS 27.1, Xcode 27.1: folded full screen reports 396, 174, 44×470 and 394, 174, 48×470, matching the measurements above. Shift 40 / Inset 24 / Content box report the same column in provider coordinates (356, 174, 372, 150, 372, -6). Unfolded reports 881, 124, 44×511. Half-open (130°) reports the division plus the same unfolded bar region. Folding open → half-open → closed returns to the folded values. Tapping the bar button on the folded Duo increments the counter.
  • iPhone 18 Pro simulator, iOS 27.0, same build: Bar 44: none, Bar 48: none, no button.
  • Android 7.6in Foldable emulator, API 34: debug and release builds both show Bar 44: none, Bar 48: none, measurement Ready (release with the corner cutout overlay, which still reports its occlusion).
  • Unit tests cover the extent registration (dedupe, unregister on unmount, extent change), null before measurement, edge mapping and none, and the outside-provider error. e2e/android.mjs now also asserts both bar lines read none.

Not verified

  • A leading-edge bar: I couldn't get the simulator to report UIVerticalBarEdgeLeading, so the leading branch is untested.
  • UIVerticalBarBehaviorDisabled: I assume the window region is empty then and the hook returns null, but the example doesn't set it.
  • The pre-27.1 SDK compile-out: only Xcode 27.1 is installed locally. The ios CI job builds on the macos-26 runner with its default Xcode, which should exercise it.
  • pnpm run e2e:android end to end: the release build installed, but on this heavily loaded host the emulator showed system ANR dialogs and the script's snapshot ran before the app was in front, and the API 34 image reported no hinge division. The new assertion is only checked by hand (screenshot above). The e2e-android label on this PR runs it in CI.
  • An iOS e2e assertion: the iOS e2e script is in Add a local iPhone Duo end-to-end check #36, not on main. Once it lands it can assert the folded bar region.

Report where UIKit places bar content of a given extent, such as the
iPhone Duo status column, relative to the nearest provider. The provider
passes the requested extents to the native view, which measures them in
the same pass as divisions and occlusions. Android and iOS before 27.1
return null.
@ayberkanilatsiz

Copy link
Copy Markdown

Perfect thx

This branch has not been deployed

No deployments
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.

2 participants