Repository navigation
[iOS] Populate display features from the iPhone Duo hinge and reserved regions - #193025
berkaycatak wants to merge 7 commits into
Conversation
…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
|
The state mapping from With the
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 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 |
|
@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 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 Plan: report the fold only while |
…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.
|
@erkamyaman Pushed. A division is now only reported while the hinge is partially open, which removes the stale 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 |
|
I'm glad it helped |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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
#endifThere was a problem hiding this comment.
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; |
There was a problem hiding this comment.
There was a problem hiding this comment.
Same as above: FlutterViewController_Internal.h is only imported from Objective-C++.
…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.
|
The guard is the piece I'd want settled before this lands, because it decides whether the feature reaches users or only the tree.
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:
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. |
|
@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. @flutter/team-ios could I get a call on this? Options as I see them:
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. |
- 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
…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.
…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.
…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.
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.
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.
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.
| // 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Also note that we will need to wait for our CI to update to Xcode/iOS 27.1.
There was a problem hiding this comment.
@hellohuanlin Any update on this? Were you able to test it?
|
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.
…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.
|
Tracking from Fieldist (https://github.com/Fieldist/fieldist/issues/2941), same as #192515. We will replace the iOS Duo inner-size |
…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
…#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]>
MediaQuery.displayFeatureshas only ever been populated on Android; the iOS embedder never filled inViewportMetrics.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
UIHingeInteractionandUIView.reservedRegions(kind:options:). This PR addsFlutterDisplayFeaturesMonitor, 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 thatFlutterViewControllerscales 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 stopDisplayFeatureSubScreenfrom splitting a layout when no fold is visible:dart:uihas no closed posture, and the view is on the cover display, which has no foldisActivelags it (see below), so the posture decides whether a fold existspostureHalfOpenedstill satisfiesavoidBoundscutoutwith theunknownstate thatdart:uiassertsDivisions are reported as
fold, nothinge: 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-moveisActive: 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.FlutterViewControlleralready refreshes fromviewDidLayoutSubviews, 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 >= 270100because 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.angleis 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 thefoldablepackage (source) before being ported here; that package also carries the hinge angle and posture, which are outside theDisplayFeaturemodel and stay there.Testing. 14 tests in
FlutterDisplayFeaturesTest.mm: ten for the mapping (run on every builder) and four forapplyDisplayFeatures:following the existingOCMPartialMockconvention. 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 fromviewDidLayoutSubviews). Unchanged features are deduplicated and not resent.Fixes #192515
Pre-launch Checklist
///).