Skip to content

[iOS] Populate display features from the iPhone Duo hinge and reserved regions - #193025

Open
berkaycatak wants to merge 7 commits into
flutter:masterfrom
berkaycatak:ios-display-features
Open

berkaycatak wants to merge 7 commits into
flutter:masterfrom
berkaycatak:ios-display-features

Conversation

@berkaycatak

@berkaycatak berkaycatak commented Sep 18, 2026 •

Copy link
Copy Markdown

MediaQuery.displayFeatures has only ever been populated on Android; the iOS embedder never filled in ViewportMetrics.physical_display_features_*. On the iPhone Duo the fold and the under-display camera are therefore invisible to Dart.

iOS 27.1 exposes both through UIHingeInteraction and UIView.reservedRegions(kind:options:). This PR adds FlutterDisplayFeaturesMonitor, which installs the interaction on the Flutter view, re-reads the reserved regions on every hinge update and layout pass, and reports them as display features that FlutterViewController scales into the viewport metrics.

Mapping rules. The conversion from regions to features is a pure function (FlutterDisplayFeaturesFromRegions) kept outside the SDK guard so it is tested on every builder. It enforces five rules that stop DisplayFeatureSubScreen from splitting a layout when no fold is visible:

  • nothing is reported while the device is closed: dart:ui has no closed posture, and the view is on the cover display, which has no fold
  • a division is reported only while the hinge is partially open: the hinge status is immediate, but a region's isActive lags it (see below), so the posture decides whether a fold exists
  • inactive regions are dropped
  • regions with a zero side are dropped: a zero-width feature in postureHalfOpened still satisfies avoidBounds
  • occlusions become cutout with the unknown state that dart:ui asserts

Divisions are reported as fold, not hinge: the inner display is one continuous curved panel with no physical gap.

Regions lag the hinge, and layout is the signal. Raised in review and confirmed by instrumenting the simulator (hinge handler timestamps, a display-link poll of the division, and a swizzled viewDidLayoutSubviews). Inside the update handler the division still has its pre-move isActive: laying the device flat it clears 2 to 15 ms after the update that reports fully open, and folding it, it only becomes active about 1 s later, once the hinge settles. The bounds stay 951x669 throughout, and the 27.1 headers have no change notification for reserved regions.

What UIKit does instead is track a region read made during layout and run layout again when that region changes. With the read inside viewDidLayoutSubviews, a layout pass followed every change of the division in every run; with the read made only outside layout, no pass followed at all. FlutterViewController already refreshes from viewDidLayoutSubviews, so no timer or polling is needed, and a comment there records why the call has to stay in layout. The posture rule above covers the few milliseconds in which the hinge already reads fully open while the division still reads active.

SDK guard. The interaction code is under #if __IPHONE_OS_VERSION_MAX_ALLOWED >= 270100 because CI builds with Xcode 26.2, which does not declare these types. Built against an older SDK, FlutterDisplayFeaturesMonitor's init returns nil and nothing is reported, so callers need only @available(iOS 27.1, *). The guard is confined to one file and can be removed once CI moves to Xcode 27.1. I am aware this macro has no precedent in the engine; happy to restructure if there is a preferred approach.

Measured on the iPhone Duo simulator (iOS 27.1): the inner display is 951x669pt with a 40pt division that is inactive while flat and keeps its 40pt frame while inactive; hinge.angle is radians with 0 folded shut and π fully open; the cover display is compact width and the inner display regular. The same API path was exercised end to end via a platform channel in the foldable package (source) before being ported here; that package also carries the hinge angle and posture, which are outside the DisplayFeature model and stay there.

Testing. 14 tests in FlutterDisplayFeaturesTest.mm: ten for the mapping (run on every builder) and four for applyDisplayFeatures: following the existing OCMPartialMock convention. The monitor itself compiles only on the 27.1 SDK; the helper was syntax-checked against both the 27.0 and 27.1 SDKs. No local engine build was run; this is a draft so CI can be the first full build.

One behavior to flag: on a layout pass where features changed, metrics are sent twice (once from refresh, once from viewDidLayoutSubviews). Unchanged features are deduplicated and not resent.

Fixes #192515

Pre-launch Checklist

…d regions

MediaQuery.displayFeatures has only ever been populated on Android. The iOS
embedder never filled in ViewportMetrics.physical_display_features_*, so on the
iPhone Duo the fold and the under-display camera were invisible to Dart.

iOS 27.1 exposes both through UIHingeInteraction and
UIView.reservedRegions(kind:options:). This adds a FlutterDisplayFeaturesMonitor
that installs the interaction on the Flutter view, re-reads the reserved regions
on every hinge update and layout pass, and reports them as display features that
FlutterViewController scales into the viewport metrics.

The mapping from regions to features is a pure function kept outside the SDK
guard so it is tested on every builder. It enforces four rules that keep
DisplayFeatureSubScreen from splitting a layout when no fold is visible:
nothing is reported while the device is closed (dart:ui has no closed posture
and the view is on the cover display); inactive regions are dropped; regions
with a zero side are dropped, since a zero-width feature in postureHalfOpened
still satisfies avoidBounds; and occlusions become cutouts with the unknown
state that dart:ui asserts. Divisions are reported as fold rather than hinge:
the inner display is one continuous panel.

The interaction code is guarded with __IPHONE_OS_VERSION_MAX_ALLOWED >= 270100
because the CI toolchain is Xcode 26.2 and does not declare these types. Built
against an older SDK the monitor's init returns nil and nothing is reported;
callers need only an @available(iOS 27.1, *) check. The guard can be removed
once CI moves to Xcode 27.1.

Measured on the iPhone Duo simulator: the inner display is 951x669pt with a
40pt division that is inactive while flat; hinge.angle is radians with 0 folded
shut; the cover display is compact width and the inner display regular.

Fixes flutter#192515
@github-actions github-actions Bot added platform-ios iOS applications specifically engine flutter/engine related. See also e: labels. team-ios Owned by iOS platform team labels Sep 18, 2026
@erkamyaman

Copy link
Copy Markdown

The state mapping from hinge.status looks right. The catch I hit implementing the same thing (iPhone Duo simulator, Xcode 27.1 beta) is the regions it's combined with: inside the UIHingeInteraction handler, reservedRegionsOfKind: still returns the regions from before the hinge moved, and folding doesn't change the view's size (951×669 flat and half-open), so there's no guarantee viewDidLayoutSubviews runs to correct them.

With the isActive and zero-size filters here, that means:

  • flat → half-open drops the stale division (inactive, zero width), so no fold is reported;
  • half-open → fully open keeps the old 40 pt division as a postureFlat fold, which DisplayFeatureSubScreen still splits around.

What fixed it for me was reading the regions again after they settle. I use one re-read ~0.3 s after the hinge update, which is empirical and only checked on the simulator; re-reading until the division's isActive matches hinge.status (with a timeout) would avoid a fixed delay.

I hit this building @erkamyaman/capacitor-foldable, a Capacitor plugin with iPhone Duo and Android foldable support. The re-read is here: https://github.com/erkamyaman/capacitor-foldable/blob/3ee245a/ios/Sources/FoldablePlugin/FoldablePlugin.swift#L127-L133

@berkaycatak

Copy link
Copy Markdown
Author

@erkamyaman Thanks for the detailed report, this is a real bug. I instrumented it on the iPhone Duo simulator (hinge handler timestamps, a display-link poll of the division, and a swizzled viewDidLayoutSubviews) and can confirm both halves: the handler sees the pre-move region, and no layout pass runs because the bounds stay 951x669.

Numbers from four fold/unfold cycles: half-open → fully open, the division is still active in the handler and flips 3 to 14 ms later, with no further hinge update or layout afterwards, so the stale postureFlat fold sticks. Fully open → half-open, it only becomes active about 1 s later, once the hinge settles, so a single 0.3 s re-read would have missed it in my runs. Also, the inactive division still reports a 40 pt frame here, not zero width.

Plan: report the fold only while hinge.status is partiallyOpen, which removes the harmful case with no timer at all, and re-read until the division's isActive agrees with the status, with a timeout, as you suggested. I'll push that with tests and correct the description.

…ons to settle

Reserved regions lag the hinge. Inside the UIHingeInteraction update handler
the division still carries its pre-move isActive, and because the view's
bounds do not change while folding, neither a further hinge update nor a
layout pass is guaranteed to correct it.

Measured on the iPhone Duo simulator: laying the device flat, the division
still reads active in the update that reports fully open and clears 3 to 14 ms
later, which left a 40pt postureFlat fold splitting dialogs on a flat device.
Folding it, the division only becomes active about a second later.

A division is now reported only while the hinge is partially open, and the
monitor re-reads the regions at a short interval until they agree with the
hinge, bounded by a fixed number of attempts.
UIKit tracks a reserved-region read made during layout and runs layout again
when the region changes. FlutterViewController already refreshes the display
features from viewDidLayoutSubviews, so that pass is the signal: measured on
the iPhone Duo simulator it follows every change of the division, about a
second after the hinge reads partially open and within 15 ms of it reading
fully open. With the read made only outside layout no pass follows at all.

Remove the timed re-read and its predicate, and document why the refresh has
to stay in viewDidLayoutSubviews. The posture rule stays: it covers the few
milliseconds in which the hinge already reads fully open while the division
still reads active.
@berkaycatak

Copy link
Copy Markdown
Author

@erkamyaman Pushed. A division is now only reported while the hinge is partially open, which removes the stale postureFlat fold.

I ended up not needing a timed re-read. UIKit tracks a reserved-region read made during layout and runs layout again when that region changes: with the read inside viewDidLayoutSubviews, a layout pass followed every change of the division in my runs (about 1 s after partially open, 2 to 15 ms after fully open), and with the read only outside layout, none did. My first measurement missed that because I was reading from the hinge handler and a display link. FlutterViewController already refreshes from viewDidLayoutSubviews, so that pass is the signal. Might be worth trying in your plugin too, in place of the 0.3 s delay. Description updated.

@erkamyaman

Copy link
Copy Markdown

I'm glad it helped

@berkaycatak
berkaycatak marked this pull request as ready for review September 19, 2026 18:15
@berkaycatak
berkaycatak requested a review from a team as a code owner September 19, 2026 18:15

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces support for reporting foldable display features (such as folds and cutouts) on iOS 27.1+ by monitoring hinge interactions and reserved regions, mapping them to Flutter's viewport metrics. The feedback recommends guarding C++ headers and types in Objective-C headers with #ifdef __cplusplus to prevent compilation errors in pure Objective-C files. Additionally, it suggests using bracket notation instead of dot notation for class methods, defensively copying blocks before execution to avoid race conditions, and implementing dealloc to ensure proper resource cleanup.

Comment on lines +40 to +118
struct FlutterReservedRegionInfo {
/// In the view's logical coordinates (points).
CGRect frame;
/// True for `.division` (the fold), false for `.occlusion` (a camera).
bool isDivision;
/// A division is inactive while the device is flat. This lags the hinge.
bool isActive;
};

/// Display features in logical pixels, laid out the way `ViewportMetrics`
/// expects: four doubles per feature (left, top, right, bottom) and one type
/// and one state per feature. The caller scales `bounds` by the device pixel
/// ratio.
struct FlutterDisplayFeatureList {
std::vector<double> bounds;
std::vector<int> types;
std::vector<int> states;
};

/// Converts the hinge posture and reserved regions into display features.
///
/// Rules, each of which exists to keep `DisplayFeatureSubScreen` from
/// splitting an app's layout when there is no visible fold:
///
/// * Nothing is reported while the device is closed. `dart:ui` has no closed
/// posture, and with the device shut the view is on the cover display, which
/// has no fold.
/// * A division is only reported while the hinge is partially open. The
/// hinge status is immediate, but a region's `isActive` lags it: measured on
/// the iPhone Duo simulator, a division still reads active inside the update
/// handler that reports fully open, and clears a few milliseconds later with
/// no further hinge update or layout pass to pick that up. Trusting it would
/// leave a 40pt `postureFlat` fold splitting every dialog on a flat device.
/// * Inactive regions are never reported.
/// * A region whose shortest side is zero is never reported. Combined with
/// `postureHalfOpened` it would still satisfy
/// `DisplayFeatureSubScreen.avoidBounds` and split the screen in two.
/// * Occlusions become `cutout` with state `unknown`, which `dart:ui` asserts.
/// * Divisions become `fold`, not `hinge`: the inner display is one continuous
/// panel with no physical gap between two separate screens.
FlutterDisplayFeatureList FlutterDisplayFeaturesFromRegions(
FlutterHingeStatus status,
const std::vector<FlutterReservedRegionInfo>& regions);

/// Delivers the current display features. Called on the main thread.
typedef void (^FlutterDisplayFeaturesUpdateBlock)(const FlutterDisplayFeatureList& features);

/// Observes the hinge and the reserved regions of a view and reports them as
/// display features whenever either changes.
///
/// The hinge APIs ship in the iOS 27.1 SDK. When the engine is built against an
/// older SDK this class still exists but `init` returns nil, so callers need
/// only an `@available(iOS 27.1, *)` check.
API_AVAILABLE(ios(27.1))
@interface FlutterDisplayFeaturesMonitor : NSObject

/// Starts observing `view`. Returns nil when the SDK the engine was built with
/// does not declare the hinge APIs.
- (nullable instancetype)initWithView:(UIView*)view
onUpdate:(FlutterDisplayFeaturesUpdateBlock)onUpdate
NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;

/// Re-reads the reserved regions and reports again.
///
/// This must be called from the view controller's `viewDidLayoutSubviews`.
/// Reserved regions lag the hinge, and UIKit offers no notification for them;
/// instead it tracks a read made during layout and runs layout again when the
/// region changes. Measured on the iPhone Duo simulator: with the read inside
/// `viewDidLayoutSubviews`, a layout pass follows every change of the division
/// (about a second after the hinge reads partially open, and within 15 ms of
/// it reading fully open). With the read made only outside layout, no layout
/// pass follows at all, because the view's bounds do not change while folding.
- (void)refresh;

/// Stops observing and releases the interaction.
- (void)invalidate;

@end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

To prevent compilation errors when this header is transitively imported by pure Objective-C (.m) files, all C++-specific types, functions, and classes (which depend on C++ types like std::vector) should be guarded with #ifdef __cplusplus.

#ifdef __cplusplus
struct FlutterReservedRegionInfo {
  /// In the view's logical coordinates (points).
  CGRect frame;
  /// True for `.division` (the fold), false for `.occlusion` (a camera).
  bool isDivision;
  /// A division is inactive while the device is flat. This lags the hinge.
  bool isActive;
};

/// Display features in logical pixels, laid out the way `ViewportMetrics`
/// expects: four doubles per feature (left, top, right, bottom) and one type
/// and one state per feature. The caller scales `bounds` by the device pixel
/// ratio.
struct FlutterDisplayFeatureList {
  std::vector<double> bounds;
  std::vector<int> types;
  std::vector<int> states;
};

/// Converts the hinge posture and reserved regions into display features.
///
/// Rules, each of which exists to keep `DisplayFeatureSubScreen` from
/// splitting an app's layout when there is no visible fold:
///
/// * Nothing is reported while the device is closed. `dart:ui` has no closed
///   posture, and with the device shut the view is on the cover display, which
///   has no fold.
/// * A division is only reported while the hinge is partially open. The
///   hinge status is immediate, but a region's `isActive` lags it: measured on
///   the iPhone Duo simulator, a division still reads active inside the update
///   handler that reports fully open, and clears a few milliseconds later with
///   no further hinge update or layout pass to pick that up. Trusting it would
///   leave a 40pt `postureFlat` fold splitting every dialog on a flat device.
/// * Inactive regions are never reported.
/// * A region whose shortest side is zero is never reported. Combined with
///   `postureHalfOpened` it would still satisfy
///   `DisplayFeatureSubScreen.avoidBounds` and split the screen in two.
/// * Occlusions become `cutout` with state `unknown`, which `dart:ui` asserts.
/// * Divisions become `fold`, not `hinge`: the inner display is one continuous
///   panel with no physical gap between two separate screens.
FlutterDisplayFeatureList FlutterDisplayFeaturesFromRegions(
    FlutterHingeStatus status,
    const std::vector<FlutterReservedRegionInfo>& regions);

/// Delivers the current display features. Called on the main thread.
typedef void (^FlutterDisplayFeaturesUpdateBlock)(const FlutterDisplayFeatureList& features);

/// Observes the hinge and the reserved regions of a view and reports them as
/// display features whenever either changes.
///
/// The hinge APIs ship in the iOS 27.1 SDK. When the engine is built against an
/// older SDK this class still exists but `init` returns nil, so callers need
/// only an `@available(iOS 27.1, *)` check.
API_AVAILABLE(ios(27.1))
@interface FlutterDisplayFeaturesMonitor : NSObject

/// Starts observing `view`. Returns nil when the SDK the engine was built with
/// does not declare the hinge APIs.
- (nullable instancetype)initWithView:(UIView*)view
                             onUpdate:(FlutterDisplayFeaturesUpdateBlock)onUpdate
    NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;

/// Re-reads the reserved regions and reports again.
///
/// This must be called from the view controller's `viewDidLayoutSubviews`.
/// Reserved regions lag the hinge, and UIKit offers no notification for them;
/// instead it tracks a read made during layout and runs layout again when the
/// region changes. Measured on the iPhone Duo simulator: with the read inside
/// `viewDidLayoutSubviews`, a layout pass follows every change of the division
/// (about a second after the hinge reads partially open, and within 15 ms of
/// it reading fully open). With the read made only outside layout, no layout
/// pass follows at all, because the view's bounds do not change while folding.
- (void)refresh;

/// Stops observing and releases the interaction.
- (void)invalidate;

@end
#endif

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same as above: this header is Objective-C++ only, matching the other internal headers here.

* Exposed so the path from features to metrics can be tested without the hinge
* hardware or the iOS 27.1 SDK.
*/
- (void)applyDisplayFeatures:(const FlutterDisplayFeatureList&)features;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Since FlutterDisplayFeatureList is a C++ struct, this method declaration must be guarded with #ifdef __cplusplus to allow the header to be imported by pure Objective-C files.

#ifdef __cplusplus
- (void)applyDisplayFeatures:(const FlutterDisplayFeatureList&)features;
#endif

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same as above: FlutterViewController_Internal.h is only imported from Objective-C++.

aayushkedawat added a commit to aayushkedawat/bifold that referenced this pull request Sep 20, 2026
…ring, capture accessory

The iOS 27.1 SDK made the rest of the fold surface verifiable, so this builds
out the remaining capabilities it exposes.

The camera capture accessory is NOT SwiftUI-only. Widely repeated community
reporting says CameraCaptureAccessory is invisible to Objective-C; the SDK
shows UISceneAccessory, cameraCaptureSceneAccessoryWithConfiguration: and
UIViewController.registerSceneAccessory: all living in UIKit, with
UIWindowSceneSessionRoleCameraCaptureAccessory as the session role. SwiftUI's
CameraCaptureAccessory wraps the same machinery. Verified by registering one
successfully on a booted iPhone Duo.

Added:

- FoldInfo.horizontalSizeClass / verticalSizeClass / isRegular, from the trait
  collection. Apple's guidance is to lay out against size classes because the
  inner display ignores supported interface orientations.
- FoldInfo.verticalBarEdge, from UITraitCollection.verticalBarEdge.
- BifoldDisplayFeatures, an opt-in bridge republishing the fold as
  MediaQuery.displayFeatures for packages written against Android foldables.
  Mapping matches flutter/flutter#193025, and it stands down automatically once
  the engine populates the field itself, so it cannot fight a future release.
- BifoldGrid, which breaks rows at the crease so no tile is drawn bent.
- bifoldAnchorPoint, placing dialogs in the larger half instead of astride the
  fold.
- BifoldCaptureAccessory, presenting content on the outer display during
  capture, rendered by a second engine from a FlutterEngineGroup.

Accessory availability is refreshed from layoutSubviews rather than KVO,
because the header documents it as observable during updateProperties and
layoutSubviews — UIKit's observation tracking, which has no key path to
subscribe to from a runtime-resolved property.

Verified on a booted Duo: registration succeeds, availability is correctly
false with no capture session, and the hinge reports a live angle.
@diegolopezrm

Copy link
Copy Markdown
Contributor

The guard is the piece I'd want settled before this lands, because it decides whether the feature reaches users or only the tree.

__IPHONE_OS_VERSION_MAX_ALLOWED >= 270100 compiles the monitor out on anything older, and CI builds the shipped iOS artifacts with Xcode 26.2 — sdk_version: "17c52", in all eight framework entries of .ci.yaml and in all ten builds in mac_ios_engine.json. So on today's CI this lands green and populates nothing, and stays that way until the SDK moves.

Worth knowing what that move has cost before. Xcode 26 shipped in September 2025 and CI landed it on 2026-04-28 in #185651 — the fourth attempt, after #179810, #185083 and #185431 were each reverted within a day or two. Seven and a half months.

Two ways not to wait for it, both with precedent here:

  • Pin one builder. Update integration test for iOS deployment workflows #173566 put Mac_ios ios_debug_workflow on an Xcode 26 beta in August 2025, a month before that SDK was final, while everything else stayed on 16.2. That compiles and tests the monitor; it does not put it in the artifacts people download.
  • Look the API up at runtime, NSClassFromString plus respondsToSelector:. That compiles against any SDK and would ship the day this lands. It is what RuntimeHingeSource and RuntimeRegionSource already do in foldable, so you know the shape of it better than I do. The engine has no precedent for it, so it needs a call from the team.

I can't check your simulator numbers yet — 27.1 wants macOS 26.6 and I'm one version behind — but once I'm up I'll run it against #192721, which animates the child when the features change, and report how the transition looks end to end.

@berkaycatak

Copy link
Copy Markdown
Author

@diegolopezrm Good catch, and you're right on the facts: the guard means this lands green and ships nothing until the CI SDK moves.

The compile-time guard was deliberate. The hinge and reserved-region APIs are still in the 27.1 beta, and with the guard a rename before GM fails loudly at build time. A runtime lookup would compile everywhere but fail silently, and as you say the engine has no precedent for it. So I'd rather not pick that on my own.

What I can say is that the switch would be small. FlutterDisplayFeaturesFromRegions is already SDK independent and covered by the tests; only FlutterDisplayFeaturesMonitor touches the new symbols, so a runtime variant would be contained to that one class. I've done the same in foldable, so I can put it up quickly if that's the direction.

@flutter/team-ios could I get a call on this? Options as I see them:

  1. Keep the compile-time guard and wait for the CI SDK.
  2. Same, plus pin one builder to Xcode 27.1 so the monitor is at least compiled and tested.
  3. Runtime lookup in the monitor, so it ships on landing.

And thanks for offering to run it against #192721. An end to end check of the transition would be very useful once you're on macOS 26.6.

channingchea added a commit to channingchea/scale-runner that referenced this pull request Sep 25, 2026
- New Fold abstraction (lib/ui/fold.dart): posture detection
  (tabletop/book), FoldScope, FoldGap, FoldAnimatedHeight, BookPage,
  and FoldSplitRow for crease-aware layouts. Source-compatible with
  Flutter's native Android foldable support and the upcoming
  first-party iOS support (flutter/flutter#193025), so the custom
  bridge can be dropped later with no call-site changes.
- Native iOS bridge (ios/Runner/FoldBridge.swift) using iOS 27.1's
  UIHingeInteraction/reservedRegions APIs, streamed to Flutter via
  EventChannel.
- All 7 practice screens (Scale Running, Quiz, Jam Mode, Inversions,
  Voicing Drill, Voicing Capture, Free Play) get tabletop
  music-stand split, book-posture prompt splitting, seamless
  mid-fold continuation, and bigger keys on the unfolded inner
  screen.
- Fixes from hardware-sim testing: Jam Mode prompt text no longer
  jumps as chord names change width (FoldSplitRow gives each half
  fixed space); book posture now splits prompts the same way
  tabletop does; guitar Start button no longer hidden under the
  fretboard on regular portrait phones (new guitarBoxHeight helper).
- Tabletop discovery hint, rotate-hint banner updated for fold
  awareness.
- Widget/integration tests (test/fold_layout_test.dart) plus a
  DUO_PREVIEW screenshot harness (test/duo_preview_test.dart) for
  visual QA across Duo poses without needing the simulator UI.
- Plan captured in docs/plans/iphone-duo-support.md.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RGHp17wtCE3hbMjYyr2Vvb
sdegenaar pushed a commit to sdegenaar/liquid_glass_widgets that referenced this pull request Oct 1, 2026
…bar strip

An app built against the iOS 27.1 SDK gets vertical bars on iPhone Duo:
the back button, bar items and tab bar stack in an 84pt strip beside the
status bar, on the outer display and on the inner display in landscape.
UIKit only moves the bars a container owns, and a FlutterViewController
owns none, so the shell stands in for the container.

GlassVerticalBar publishes the strip from the shell. It is read from
MediaQuery.viewPadding: a zero top inset with exactly one lateral inset is
the strip, on that side. Where the first and last controls sit is not in
the insets, so it comes from a table of the postures measured on the 27.1
simulator until flutter/flutter#193025 surfaces the status cluster and the
camera.

The pinned host's cluster, groups and sides learn an axis, so the chrome
stacks down the strip and keeps its morph across push and pop. The title
stays in a row at the top of the content, beside the items that stay
horizontal (GlassBarItem.axisBehavior). GlassTabBar.bottom and
.minimizable become an icon-only capsule at the bottom of the strip and
GlassToolbar stacks its items there; both reserve their height with the
shell, and the chrome collapses what no longer fits into a ••• menu. The
tab bar collapses to its selected tab in outer landscape, or always under
GlassVerticalBarCompression.prefersBarItems.
@cbracken
cbracken requested a review from LouiseHsu October 1, 2026 21:45
@cbracken cbracken added the CICD Run CI/CD label Oct 1, 2026
sdegenaar pushed a commit to sdegenaar/liquid_glass_widgets that referenced this pull request Oct 5, 2026
…bar strip

An app built against the iOS 27.1 SDK gets vertical bars on iPhone Duo:
the back button, bar items and tab bar stack in an 84pt strip beside the
status bar, on the outer display and on the inner display in landscape.
UIKit only moves the bars a container owns, and a FlutterViewController
owns none, so the shell stands in for the container.

GlassVerticalBar publishes the strip from the shell. It is read from
MediaQuery.viewPadding: a zero top inset with exactly one lateral inset is
the strip, on that side. Where the first and last controls sit is not in
the insets, so it comes from a table of the postures measured on the 27.1
simulator until flutter/flutter#193025 surfaces the status cluster and the
camera.

The pinned host's cluster, groups and sides learn an axis, so the chrome
stacks down the strip and keeps its morph across push and pop. The title
stays in a row at the top of the content, beside the items that stay
horizontal (GlassBarItem.axisBehavior). GlassTabBar.bottom and
.minimizable become an icon-only capsule at the bottom of the strip and
GlassToolbar stacks its items there; both reserve their height with the
shell, and the chrome collapses what no longer fits into a ••• menu. The
tab bar collapses to its selected tab in outer landscape, or always under
GlassVerticalBarCompression.prefersBarItems.
sdegenaar pushed a commit to sdegenaar/liquid_glass_widgets that referenced this pull request Oct 8, 2026
…strip

The strip's first control sat at a height measured on the 27.1
simulator, so on a device, where the cluster grows with live activities
and goes with the status bar, the controls no longer lined up with it.

The strip's ends now come from the regions UIKit reserves for the
cluster and the camera, UIView.reservedRegions(kind: .occlusion), read
by a small iOS plugin and updated as they change. GlassVerticalBar.resolve
takes them as cutout displayFeatures, which is where Flutter will report
them once it does on iOS (flutter/flutter#193025): the controls start
below the cutouts in the top half of the strip and end above those in
the bottom half. Until a region is reported, the simulator table is the
fallback.
rwecho added a commit to rwecho/MV2 that referenced this pull request Oct 9, 2026
Two reports from the device: while unfolded the rail's top (search /
avatar) sat under the Duo's own status column, and while folded the
trailing strip stayed empty.

- The strip is what qualifies for the rail, not the window width: both
  Duo states reserve a trailing inset (84pt) wider than the leading one,
  so the folded cover display now hosts the rail too (single column, no
  floating bar). Phones keep symmetric insets and iPads have no strip, so
  they are unaffected.
- The rail starts below the system's own column: mv2RailTopInset() reads
  UIStatusBarManager.statusBarFrame through the new uiGeometry bridge
  method (the Duo draws the island + status items vertically in that
  strip), and only then floats the glass.
- Forward compatible with Flutter's own channel: when the engine populates
  MediaQuery.displayFeaturesOf for the Duo's reserved region
  (flutter/flutter#193025), that feature wins for both the strip width and
  the top inset — one file changes, the UI does not. The engine in Flutter
  3.47.3 reports an empty list today, measured on the device.
rwecho added a commit to rwecho/MV2 that referenced this pull request Oct 9, 2026
UIKit reports only a 2pt status-bar frame on the Duo (measured), and the
engine in Flutter 3.47.3 reports no display features, so how far the
system's island/status column reaches down the trailing strip cannot be
read yet. Keep the rail's controls in the lower part of the strip instead
— where Apple's own Duo mockups place the toolbar and the tab bar — so
the column at the top stays clear (the reported avatar overlap); topInset
still clamps it once the reserved region is known.

Note for the future: flutter/flutter#193025 populates exactly that region
as a display feature, and scene_geometry.dart already prefers it.
rwecho added a commit to rwecho/MV2 that referenced this pull request Oct 9, 2026
The system's own column in the Duo's trailing strip cannot be read from
UIKit (statusBarFrame reports ~2pt) or from the engine on Flutter 3.47.6
(displayFeatures still empty, displayCornerRadii null). Measure it from
the frame instead: in the pre-27.1-SDK build the window stopped 80pt short
of the inner display, so those pixels are purely system-drawn — clock at
y 34..46pt, status icons at 57..88pt, island above them. 96pt clears all
three.

The rail is laid out as two groups (page actions below that clearance,
destinations along the bottom), which is where Apple's Duo mockups put the
toolbar and tab bar. Verified live on the folded cover display: the glass
column starts at y=96.

A DisplayFeatureType.cutout from the engine still wins when it appears
(flutter/flutter#193025), with the measured value as the floor.
@hellohuanlin
hellohuanlin requested review from LongCatIsLooong and removed request for LouiseHsu October 10, 2026 17:35
// This read has to happen during layout. Reserved regions lag the hinge and
// UIKit has no notification for them; it tracks a read made here and runs
// layout again when a region changes, which is the only signal there is
// while folding, since the view's bounds stay the same.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When I switch from half-open to open state on an iPhone Duo, viewDidLayoutSubviews is not called, despite that reserved region is changed.

Another example is when inner display's under screen camera shows up, we don't have a physical iPhone duo yet, but I doubt viewDidLayoutSubviews would be called either.

@berkaycatak berkaycatak Oct 16, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I could not reproduce this on the simulator. To rule out the embedder I measured it outside Flutter: DuoProbe.m is a 70 line UIKit app with the same shape as this PR, a UIHingeInteraction on the view controller's root view, the same two reservedRegionsOfKind: queries in viewDidLayoutSubviews, and a display link reading the regions through a separate sibling view only to report the true moment of change.

Xcode 27.1 (27A9275), iOS 27.1 simulator (24A94401), inner display landscape 951x669, hinge driven to 120° and 180° three times each:

transition region seen inside the hinge handler viewDidLayoutSubviews after
half-open → open (3x) still isActive == YES called, with isActive == NO 1 ms
open → half-open (3x) still isActive == NO called, with isActive == YES about 0.56 s

The sibling poll sees the change in the same millisecond as the layout pass or a few ms later, so the pass is caused by the region change. Six transitions out of six. A control build of the same app without the read in layout saw the same region changes and no layout pass at all.

One thing that produces exactly the symptom you describe: the app is not frontmost. Hinge updates keep arriving, but the regions freeze at their last value and no layout pass follows, so the handler reports a stale region indefinitely. Could that match your setup? My machine cannot handle an engine build, so I cannot run the PR itself end to end; if you can share how you reproduced it I will match it in the probe.

The camera occlusion is not covered by this measurement; I cannot trigger the under-display camera on the simulator.

Separately, master is merged into the branch to resolve the FlutterViewController.mm conflict from #191444.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We shouldn't rely on hinge handler for reserved region update. the hinge handler is called every time the hinge changes its angle even slightly, but reserved region doesn't change this often.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Agreed, and changed in a7ae4e0. The handler no longer reads the regions. It records the posture and, only when that changes, requests a layout pass; viewDidLayoutSubviews is now the one place the regions are read. Verified with the probe: the handler fires twice per fold cycle rather than on every angle sample, the requested pass runs within a millisecond, and the pass UIKit runs on its own when the division becomes active follows about half a second later, in every cycle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@berkaycatak This is odd. Can you show a screenshot? When i tried it, viewDidLayoutSubviews is not called when switching from half-open state to open state, despite that the restricted region has changed.

@hellohuanlin hellohuanlin Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A read of the regions made during layout is what makes UIKit run layout again when they change

Wow that's a surprise and sounds like an iOS bug - why would read during layout trigger another layout?

Can you reduce the test project to demonstrate that only? Your test project has things like hinge interaction which is a bit distracting(sorry i was reading the wrong file)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I was able to confirm that reading it in didLayoutSubview made it work.

There's another issue with under-display camera of the inner display. We are getting a Duo phone (already ordered). Let's hold this until we try it out since it's unknown yet.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for checking. For the record, it is documented rather than a quirk. From Apple's UIView.ReservedRegion page, https://developer.apple.com/documentation/uikit/uiview/reservedregion : "UIKit tracks the regions you read in updateConstraints() and runs it again when they change, so the constraint stays current as regions become active or inactive." And viewDidLayoutSubviews() is on Apple's list of methods with automatic observation tracking, under "Observing data in view controllers": https://developer.apple.com/documentation/uikit/automatic-observation-tracking . I added both links to the comment at the read site in 841d7c9.

On the inner camera, the same page says under "Include inactive regions", https://developer.apple.com/documentation/uikit/uiview/reservedregion#Include-inactive-regions : "The inner camera is active only while the camera is in use, and the outer camera is always active." The simulator matches that: with includeInactive, the inner display reports two occlusion regions, the system cluster in the corner (active) and the camera (58x37 pt, inactive, since the simulator has no camera), so the camera activating cannot be produced here. The engine reads occlusions with active regions only, so that activation turns an empty result into one region; the same query shape on the division (active only) gets a layout pass on every switch, six of six. By the sentence above, the tracking covers regions becoming active, so the camera should behave the same, but that is what the device will tell. Holding until then is fine.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Also note that we will need to wait for our CI to update to Xcode/iOS 27.1.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

@hellohuanlin Any update on this? Were you able to test it?

@li-jia-nan

Copy link
Copy Markdown

Thanks for working on iPhone Duo support! I saw the recent review discussion about reserved-region updates. Could you share what still needs to be resolved before this can land, and whether there's a tentative target release?

…a pass on a posture change

The hinge update handler runs on every change of the angle, many times a
second while the device is folding, while the reserved regions change only
with the posture. Reading them from the handler coupled the two. The handler
now records the posture and, only when it changes, asks the view for a layout
pass; viewDidLayoutSubviews is the one place the regions are read.

Measured with a UIKit probe of the same shape on the iPhone Duo simulator:
the pass requested on a posture change runs within a millisecond, and the
pass UIKit runs on its own when a region changes follows about half a second
later when folding, in every cycle.
UIView.ReservedRegion documents that UIKit tracks the regions read in
updateConstraints() and runs it again when they change, and the automatic
observation tracking page lists viewDidLayoutSubviews among the tracked
methods. The comments at the read site and on refresh now point to both.
szymdzie added a commit to szymdzie/iphone_duo_ui_pro that referenced this pull request Oct 23, 2026
…1 SDK detection

* ios/iphone_duo_ui_pro/Package.swift for Flutter 3.44+, where SwiftPM is the
  default; CocoaPods stays for Flutter 3.38-3.43. Sources move to the SwiftPM
  layout, the podspec follows them.
* The bridge compiles the iPhone Duo code in when it sees the iOS 27.1 SDK
  (UIKit 9127.0.85, SwiftUI 8.0.85; 27.0 ships .84), so the Podfile flag is
  gone. Mac Catalyst is excluded. DUO_SDK_27_1 still forces it on.
* DuoDisplayFeatures skips a fold or camera the platform already reports
  (Android, flutter/flutter#193025).
* Verified with the released Xcode 27.1 (27A9275): Flutter 3.38 CocoaPods,
  Flutter 3.47 SwiftPM and CocoaPods, sdk271=true on the iPhone Duo simulator.
@jmagman jmagman added the CICD Run CI/CD label Oct 23, 2026
@JamesFieldist

Copy link
Copy Markdown

Tracking from Fieldist (https://github.com/Fieldist/fieldist/issues/2941), same as #192515.

We will replace the iOS Duo inner-size isUnfolded stand-in with displayFeatures once this PR ships.

getgroupmy pushed a commit to getgroupmy/get-ride that referenced this pull request Nov 13, 2026
…tures)

Record that Flutter itself is adding iOS display-feature population
(flutter/flutter#193025), which validates our mapping (division ->
DisplayFeatureType.fold, zero-width postureHalfOpened while the hinge is
partially open, logical-point bounds) and reframes the native plugin as a
stopgap for Flutter versions before that PR lands — mergeFoldFeatures already
yields to the engine's feature once it does. Also capture the reserved-region
timing lag (read in viewDidLayoutSubviews; no change notification).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NDfeQQMcJvAnDpYN9Mwyb6
getgroupmy added a commit to getgroupmy/get-ride that referenced this pull request Nov 13, 2026
…#687)

* iPhone Duo: window size-class + fold foundation (slice A)

First slice of adapting the UI to a foldable (outer/inner displays) without
breaking standard iPhones. A pure, tested foundation that reads the actual
window geometry and reported display features — never a device name or a
fixed size table:

- WindowWidthClass / WindowHeightClass from the window size, on the app's
  existing wide-layout thresholds (a test pins widthClassOf to wideLayoutAt
  so they can never drift).
- foldOf / postureOf: a fold or hinge from MediaQuery.displayFeatures, with
  whether it separates the window (a hinge always; a fold only half-opened —
  a flat fold is a seamless display) and which way the seam runs.
- hitsFold: keep a button/card clear of the seam. panesAround: split an area
  into two panes around the seam.
- windowMetricsFor(context): the live reader, rebuilding on fold/resize.

No-op on every current iPhone (no fold reported). Nothing consumes it yet;
later slices route the home map, confirm actions/SOS and call screen around
the fold and add the two-pane inner-display layout.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NDfeQQMcJvAnDpYN9Mwyb6

* iPhone Duo: align the home wide layout to a vertical book-fold (slice B/C)

The home screen already lays out map + booking/trip panel as two columns at
regular width. On a foldable held open as a book, line that divide up with
the fold so neither the panel nor the map crosses the crease, instead of a
fixed 420px panel that could straddle it.

- window_class: verticalSplitFor(metrics) returns the panel width and seam
  gap from a separating vertical fold, or null when there is none or the two
  panes it would make are too narrow to use (minLeft/minRight guards). Pure +
  tested.
- home_screen wide branch: panel width and the divider come from that split
  when present, else the existing fixed width + hairline divider.

No-op on every non-folding device: displayFeatures is empty, so the split is
null and the layout is byte-for-byte the current one. The folded/half-open
phone-layout sheet and horizontal (tabletop) folds are deferred to the
on-simulator pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NDfeQQMcJvAnDpYN9Mwyb6

* iPhone Duo: fold bridge + simulation harness (slice E)

Brings the Duo's crease into the app through the same MediaQuery.displayFeatures
path an Android foldable already uses, so no screen needs an iOS branch.

- core/duo_fold.dart: DuoFoldSignal (hinge pose + crease rect + occlusions),
  duoFoldFromNative (parse the channel message), displayFeatureFor (crease ->
  a half-opened fold feature only when it is actively dividing), mergeFoldFeatures
  (add the synthesized feature only when the platform reports none, so Android is
  never doubled), and simulatedBookFold / simulatedTabletopFold. Pure + tested.
- widgets/duo_fold_scope.dart: injects the native (EventChannel get_ride/duo_fold)
  or simulated crease into MediaQuery.displayFeatures. No-op when none; the native
  channel is absent off iOS and its first listen error is swallowed.
- app.dart: wraps the router content in DuoFoldScope.
- docs/iphone_duo_native_plugin.md: the iOS Swift plugin (UIHingeInteraction +
  UIView.reservedRegions, modelled on pvinis/duo-demo's WindowProbe) to build and
  verify on Xcode 27.1 — written from reference code, not compiled here. Android
  needs no plugin (Flutter already fills displayFeatures from WindowManager).

The simulation harness (duoFoldSimulation) exercises the whole layout path on an
ordinary phone, as the widget tests do.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NDfeQQMcJvAnDpYN9Mwyb6

* iPhone Duo docs: note flutter/flutter#193025 (engine fills displayFeatures)

Record that Flutter itself is adding iOS display-feature population
(flutter/flutter#193025), which validates our mapping (division ->
DisplayFeatureType.fold, zero-width postureHalfOpened while the hinge is
partially open, logical-point bounds) and reframes the native plugin as a
stopgap for Flutter versions before that PR lands — mergeFoldFeatures already
yields to the engine's feature once it does. Also capture the reserved-region
timing lag (read in viewDidLayoutSubviews; no change notification).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NDfeQQMcJvAnDpYN9Mwyb6

---------

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-ios iOS applications specifically team-ios Owned by iOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[iPhone Duo] Proposal: populate MediaQuery.displayFeatures on iOS for iPhone Duo (foldable)

8 participants