nitro_fold_duo 0.0.1
nitro_fold_duo: ^0.0.1 copied to clipboard
iPhone Duo support for Flutter backed by UIKit's reserved-region, hinge and vertical-bar APIs over Nitro FFI, with fold-aware layout and Liquid Glass chrome.
nitro_fold_duo #
iPhone Duo support for Flutter: fold and camera geometry, hinge state, and the system's vertical control bar. Bridged with Nitro over FFI — no method channels.
dependencies:
nitro_fold_duo: ^0.0.1
import 'package:nitro_fold_duo/nitro_fold_duo.dart';
SignalBuilder(
builder: (context) => Text(duoState.value.hingeStatus.name),
)
signals_flutter is re-exported, so SignalBuilder, signal and computed
come with that one import.
| Closed | Partially folded | Open flat |
|---|---|---|
![]() |
![]() |
![]() |
The example app, on an iPhone Duo running iOS 27.1. One layout, three poses.
Requirements #
| Build toolchain | Xcode 27.1 (iOS 27.1 SDK) |
| Runtime | iOS 27.1+ for fold APIs; older reports isSupported == false |
| Dart / Flutter | Dart 3, signals_flutter (re-exported) |
The reserved-region, hinge and vertical-bar APIs are introduced in 27.1 and do not exist in earlier SDKs. They are runtime-guarded. Select the toolchain per command rather than globally:
DEVELOPER_DIR=/path/to/Xcode-27.1.app/Contents/Developer flutter build ios --simulator
Platform support #
| Platform | Fold and hinge | Vertical bar |
|---|---|---|
| iOS 27.1+ | reserved regions, hinge angle, corner insets | real Liquid Glass |
| iOS < 27.1 | isSupported == false |
— |
| Android | Jetpack WindowManager + hinge sensor | — |
| macOS, Windows, Linux | isSupported == false |
— |
Every platform answers the whole API. Where there is no fold, state is
duoStateUnavailable and bar calls are accepted and ignored — no platform
checks required at the call site.
Android reports fold bounds, posture and separation through FoldingFeature,
emits an occlusion when the hinge occludes fully, reads TYPE_HINGE_ANGLE
where present, and derives corner insets from RoundedCorner on API 31+.
verticalBarEdge is always unspecified; Android keeps bars horizontal.
Getting started #
Wrap the app once, then build screens with DuoBarScaffold:
import 'package:flutter/material.dart';
import 'package:nitro_fold_duo/nitro_fold_duo.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
// Publishes the fold through MediaQuery, so dialogs and popups avoid it.
builder: (context, child) => DuoDisplayFeatures(child: child!),
home: const HomePage(),
);
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) => DuoBarScaffold(
title: const Text('Library'),
actions: [
DuoBarItem(symbol: 'square.and.arrow.up', title: 'Share', onPressed: () {}),
DuoBarItem(symbol: 'gearshape', title: 'Settings', onPressed: () {}),
],
// Falls back to your normal chrome wherever bars stay horizontal.
horizontalChrome: (context, body) =>
Scaffold(appBar: AppBar(title: const Text('Library')), body: body),
body: const Center(child: Text('Content')),
);
}
State #
duoState is a signal holding the live device state. Read it inside a
SignalBuilder and the widget rebuilds when the device folds, rotates or
changes display.
SignalBuilder(
builder: (context) {
final state = duoState.value;
return Text('${state.hingeStatus.name} · ${state.regions.length} regions');
},
)
Derived signals avoid rebuilding on unrelated changes — this line does not rebuild when a camera region moves:
SignalBuilder(builder: (context) => Text(duoHingeStatus.value.name))
| Signal | Type | Description |
|---|---|---|
duoState |
DuoState |
Full snapshot |
duoActiveDivision |
DuoReservedRegion? |
Fold currently dividing the display |
duoActiveOcclusions |
List<DuoReservedRegion> |
Regions that can obscure content |
duoHingeStatus |
DuoHingeStatus |
unknown · closed · partiallyOpen · fullyOpen |
duoVerticalBarEdge |
DuoVerticalBarEdge |
unspecified · leading · trailing |
duoCornerInsets |
EdgeInsets |
Clearance required by rounded display corners |
duoState always holds a value. Where no bridge exists — under flutter test,
or on an unsupported platform — it reports duoStateUnavailable rather than
throwing. The native subscription starts on first read and is shared across
listeners.

Asking the simulator for 135° returns 132.5°, which is why the posture is the thing to branch on.
DuoState #
| Field | Type | |
|---|---|---|
isSupported |
bool |
Whether the device reports fold geometry at all |
hingeStatus |
DuoHingeStatus |
Posture |
hingeAngle |
double? |
Radians, where the sensor exists |
verticalBarEdge |
DuoVerticalBarEdge |
Edge the system reserves for its bar |
regions |
List<DuoReservedRegion> |
Folds and cameras, active and inactive |
cornerInsets |
DuoInsets |
Display corner clearance; .edgeInsets converts |
A DuoReservedRegion has kind (division or occlusion), isActive, and
geometry read through the rect, margins and isHorizontalBand extensions.
rect is in Flutter logical pixels relative to the Flutter view, and
already includes margins. Do not inset by them again.
Branch on hingeStatus, not hingeAngle. No numeric range or zero
convention is documented, update rate is system policy, and requesting 135°
returns 132.5°.
Widgets #
| Widget | Description |
|---|---|
DuoBuilder |
Rebuilds on geometry or hinge change |
DuoSplit |
Places two panes either side of an active fold |
DuoOcclusionSafeArea |
Insets a child clear of active occlusions |
DuoDisplayFeatures |
Publishes fold and cameras through MediaQuery |
DuoBarScaffold |
Reserves the vertical strip and draws the bar |
DuoVerticalBar |
The strip itself, for a custom scaffold |
DuoGlassCapsule |
One UIGlassEffect capsule of icon buttons |
DuoGlassSurface |
The same material without buttons |
DuoBuilder #
The whole snapshot, without writing a SignalBuilder:
DuoBuilder(
builder: (context, state) => Text(
state.isSupported ? state.hingeStatus.name : 'not a foldable',
),
)
DuoSplit #
Places primary and secondary on opposite sides of an active fold and
leaves the reserved band empty. It is the layout in every pose, not a variant
swapped in when the device bends.
DuoSplit(
primary: const ArticleList(),
secondary: const ArticleDetail(),
)

With no fold crossing the box, the panes share it the way the system's split
arrangement does: side by side when the box is wider than it is tall, stacked
when it is taller than it is wide. Pass fallbackAxis to pin one axis.
| Parameter | |
|---|---|
primary, secondary |
Required. Leading/top and trailing/bottom panes |
fallbackAxis |
Axis to pin when no fold applies; null follows the box |
band |
Drawn inside the reserved crease; nothing tappable belongs here |
Give each pane its own scroller rather than running one across both. Do not
nest a DuoSplit inside a scroll view or a navigation container.
DuoOcclusionSafeArea #
Insets a child clear of whatever can obscure it — on Duo, the camera. Each region is cleared from the single cheapest edge it touches, so a corner camera costs one side rather than two.
DuoOcclusionSafeArea(
minimum: const EdgeInsets.all(8),
child: const Text('Never under the camera'),
)
iOS already folds some of this into the safe area. Nesting this inside a
SafeArea can inset twice for the same camera — use one or the other per box.
DuoDisplayFeatures #
dart:ui populates MediaQuery.displayFeatures on Android only. On iPhone Duo
it is empty, so DisplayFeatureSubScreen — and every dialog, popup and route
built on it — has no fold to avoid.
MaterialApp(
builder: (context, child) => DuoDisplayFeatures(child: child!),
home: const HomePage(),
)

The dialog above is an ordinary showDialog. It sits in one half because the
fold reached MediaQuery; open the device flat and it centres across the
whole display.
An active division becomes a fold feature carrying its posture; a camera
becomes a cutout. Platform-reported features are preserved, so an Android
foldable keeps its own. A flat fold is reported inactive by the device and is
not published.
The pure mapping is available on its own:
final features = duoDisplayFeatures(duoState.value);
DuoBarScaffold #
On iPhone Duo the toolbar and tab bar move to a vertical strip at the side of
the display, as native Liquid Glass capsules. This reserves that strip, draws
the bar in it, and keeps body clear.

DuoBarScaffold(
title: const Text('Library'),
leading: DuoBarItem(
symbol: 'chevron.backward',
title: 'Back',
onPressed: () => Navigator.of(context).pop(),
),
actions: [
DuoBarItem(symbol: 'square.and.arrow.up', title: 'Share', onPressed: share),
// endsGroup starts a new capsule, keeping the pairing a toolbar would show.
DuoBarItem(symbol: 'bookmark', title: 'Save', onPressed: save, endsGroup: true),
DuoBarItem(symbol: 'gearshape', title: 'Settings', onPressed: settings),
],
tabs: [
DuoBarItem(symbol: 'text.justify', title: 'Read', onPressed: () => go(0)),
DuoBarItem(symbol: 'info.circle', title: 'State', onPressed: () => go(1)),
],
selectedTab: tab,
horizontalChrome: (context, body) => Scaffold(
appBar: AppBar(title: const Text('Library')),
bottomNavigationBar: myTabBar,
body: body,
),
body: body,
)
| Parameter | |
|---|---|
body |
Required. Laid out clear of the strip |
title |
Shown at the leading edge above body while the strip exists |
leading |
Primary navigation control, placed first in the strip |
actions |
Toolbar items, grouped into capsules |
tabs, selectedTab |
Tab bar, drawn as one capsule at the bottom |
horizontalChrome |
Your normal chrome, used where there is no strip |
background |
Drawn behind everything, full width, under the strip |
titleBackdrop |
System material behind title; true by default |
tint, style |
See Styling |
Where the system keeps bars horizontal — the inner display in portrait, and
every other iPhone — the strip does not exist and horizontalChrome draws your
ordinary app bar and tab bar around body instead. Omit it and body is used
bare.
background is for a hero or header image, which the system lets run past the
safe area and under the bar — UIBackgroundExtensionView in UIKit. body
stays inset.
DuoBarScaffold(
background: const DecoratedBox(decoration: BoxDecoration(gradient: brand)),
body: body,
)
The strip is composed in Flutter. iOS only moves bars it manages itself,
meaning navigationItem groups under a UINavigationController, which a
Flutter app does not have; the layout, ordering and overflow rule are
reimplemented here to match. The capsules, their buttons and the overflow menu
are real UIKit. Not implemented: badges, axisBehavior,
UIVerticalBarBehavior.disabled.
DuoBarItem #
One icon button, named by its SF Symbol.
DuoBarItem(
symbol: 'ellipsis',
title: 'More',
menu: [
DuoBarItem(symbol: 'pencil', title: 'Edit', onPressed: edit),
DuoBarItem(symbol: 'trash', title: 'Delete', onPressed: delete),
],
)
| Field | |
|---|---|
symbol |
Required. SF Symbol name, e.g. square.and.arrow.up |
title |
Required. Accessibility label, and the wording used in a menu |
onPressed |
Unused when menu is non-empty — the button opens the menu |
endsGroup |
Start a new capsule after this item |
menu |
Entries behind this item in a real UIMenu |
visibilityPriority |
How long this item keeps its place in the strip |
symbol and title are both required because the system needs both: the
symbol for a vertical presentation, the title for a menu or an expanded form.
Items that do not fit the strip move into an overflow capsule with a real
UIMenu. Room for that capsule is taken out of the budget first, so a bar
never overflows by exactly one item. Groups stay whole and in order.
Visibility priority #
Left alone, a bar overflows bottom to top. DuoBarVisibilityPriority changes
that order — the mirror of ToolbarItemVisibilityPriority:
DuoBarItem(
symbol: 'square.and.pencil',
title: 'Compose',
onPressed: compose,
visibilityPriority: DuoBarVisibilityPriority.high,
)
| Value | |
|---|---|
automatic |
The default. Position alone decides |
low |
Into the menu before anything default or high |
high |
Stays in the strip longer. The action people reach for most, and anything carrying status |
lowerThan(p), higherThan(p) |
A rank either side of another |
The lowest priority goes first, and between equals the capsule nearest the bottom. Capsules move whole, so a capsule is as important as its most important button — give a group one priority to move it as a unit.
DuoVerticalBar #
The strip on its own, for a scaffold you lay out yourself. It handles status clearance, the leading control, toolbar groups, overflow and the tab bar.
Row(
children: [
Expanded(child: body),
DuoBuilder(
builder: (context, state) => DuoVerticalBar(
state: state,
leading: DuoBarItem(symbol: 'chevron.backward', title: 'Back', onPressed: pop),
actions: actions,
tabs: tabs,
selectedTab: tab,
),
),
],
)
DuoGlassCapsule #
A single capsule of icon buttons — the real UIGlassEffect material on iOS,
hosted as a platform view, and a blurred capsule of the same footprint
elsewhere. Size it yourself.
SizedBox(
width: 52,
height: 44.0 * items.length,
child: DuoGlassCapsule(
items: items,
selectedIndex: 1,
tint: const Color(0xFF6750A4),
symbolPointSize: 20,
),
)
DuoGlassSurface #
The same material without buttons, for chrome that content scrolls under. It is decorative: touches pass through to whatever sits on top.
DuoGlassSurface(
borderRadius: 16,
tint: const Color(0x226750A4),
child: const Padding(
padding: EdgeInsets.all(12),
child: Text('Library'),
),
)
Styling #
DuoBarStyle carries every measurement and tint the bar draws with, defaulting
to system values. Apply per bar, or once via DuoBarTheme:
DuoBarTheme(
style: const DuoBarStyle(
capsuleWidth: 52,
symbolPointSize: 20,
tint: Color(0xFF6750A4),
titleBackdropRadius: 16,
),
child: DuoBarScaffold(body: body),
)
| Field | Controls | Default |
|---|---|---|
capsuleWidth |
Width of a capsule, centred in the strip | 44 |
itemHeight |
Height of one button inside a capsule | 44 |
groupSpacing |
Space between capsules | 12 |
edgeMargin |
Space kept from a free window edge | 24 |
symbolPointSize |
SF Symbol size inside a capsule | 17 |
stripWidth |
Overrides the system's reserved inset | system |
tint |
Capsule button colour | system label |
titleBandHeight |
Height of the leading-edge title band | 70 |
titlePadding |
Insets the title inside its band | start: 20 |
titleBackdropRadius |
Corner radius of the material behind the title | 0 |
overflowSymbol, overflowTitle |
The overflow capsule | ellipsis, More |
compression |
What gives way when the strip runs out | automatic |
copyWith covers every field, so a theme can be adjusted for one screen:
DuoBarScaffold(
style: DuoBarTheme.of(context).copyWith(tint: Colors.orange),
body: body,
)
DuoBarStyle.compression mirrors UIVerticalBarCompressionBehavior
(SwiftUI: .toolbarVerticalCompressionBehavior(.prefersToolbarItems)):
| Value | Behaviour |
|---|---|
automatic, prefersTabBar |
Tab bar stays whole; toolbar items overflow |
prefersBarItems |
Toolbar keeps the strip; tab bar collapses to one button with a menu |
Other widgets take their own overrides: DuoSplit.band and
DuoSplit.fallbackAxis, DuoOcclusionSafeArea.minimum, and
DuoGlassSurface.borderRadius / .tint.
Layout helpers #
The same decisions as pure functions, for layouts the widgets do not cover.
// Where the strip is, and how wide, from the view padding.
final side = DuoLayout.barSide(MediaQuery.viewPaddingOf(context), state: state);
final width = DuoLayout.stripWidth(MediaQuery.viewPaddingOf(context));
// What to keep free at each end of the strip so controls clear the camera.
final (:top, :bottom) = DuoLayout.barInsets(
size: MediaQuery.sizeOf(context),
viewPadding: MediaQuery.viewPaddingOf(context),
state: state,
);
// The band dividing a box of this size, or null if no fold crosses it.
final split = duoDivisionBand(state, const Size(400, 800));
if (split != null) {
debugPrint('${split.horizontal ? 'top/bottom' : 'left/right'} at ${split.band}');
}
// Padding that clears the cameras out of that same box.
final insets = duoOcclusionInsets(state, const Size(400, 800));
// Which toolbar groups fit, and what moves into the overflow menu.
final fitted = duoBarOverflow(
groups: DuoVerticalBar.groupsOf(actions),
available: 600,
);
duoDivisionBand and duoOcclusionInsets take an origin for a box that is
not at the view's top-left. DuoState also carries activeDivision,
activeDivisions and activeOcclusions extensions.
Testing #
flutter test # 116 tests, 100% line coverage
Stage device state without hardware:
debugSetDuoState(const DuoState(
isSupported: true,
hingeStatus: DuoHingeStatus.partiallyOpen,
verticalBarEdge: DuoVerticalBarEdge.trailing,
hingeAngle: 2.2,
regions: [
DuoReservedRegion(
kind: DuoRegionKind.division,
left: 0,
top: 380,
width: 400,
height: 40,
marginLeft: 0,
marginTop: 0,
marginRight: 0,
marginBottom: 0,
isActive: true,
),
],
cornerInsets: DuoInsets(left: 0, top: 0, right: 84, bottom: 34),
));
addTearDown(() => debugSetDuoState(null));
debugSetDuoBridge replaces the whole plugin with a fake for bridge-level
tests, and debugClearDuoBridge restores the real one.
Fold poses #
hinge sets the simulator's hinge
angle from the host; Marionette reads back what the app made of it.
cd example
flutter run -d <duo-udid>
marionette register duo ws://127.0.0.1:<port>/<token>/ws
dart run tool/fold_check.dart --instance duo --device <duo-udid>
ok 0° → closed, 466x678, body 382.0pt clear of a 84.0pt strip, active division false
ok 90° → partiallyOpen, 951x669, body 867.0pt clear of a 84.0pt strip, active division true
ok 135° → partiallyOpen, 951x669, body 867.0pt clear of a 84.0pt strip, active division true
ok 180° → fullyOpen, 951x669, body 867.0pt clear of a 84.0pt strip, active division false
ok 0° → closed, 678x466, body 594.0pt clear of a 84.0pt strip, active division false
Covers the pose rows of Apple's validation matrix: cover display, both partial angles, flat, and the round trip.
Native controls #
The bar's buttons are UIKit inside a platform view. XCUITest cannot deliver
touches to them: by accessibility element and by raw coordinate alike, the tap
reports success and nothing happens. The four Patrol tests that press them are
skip: true, and the bar is verified by hand. Two tests covering the Flutter
path run:
cd example
dart pub global activate patrol_cli 4.8.0
ruby tool/setup_patrol_ios.rb # once, and after a Patrol upgrade
patrol test --target integration_test/patrol_test.dart -d <duo-udid>
patrol_cli reports Total: 0 against Xcode 27.1 result bundles even when
tests ran — still so in 4.8.0. Read the bundle instead:
xcrun xcresulttool get test-results tests --path build/ios_results_*.xcresult
Development #
nitrogen generate # regenerate bridges from lib/src/nitro_fold_duo.native.dart
nitrogen link # wire them into the native build systems
nitrogen link copies hand-written Swift from ios/Classes/ into the SPM
sources only when missing. After editing one of those files, delete the copy
under ios/nitro_fold_duo/Sources/NitroFoldDuo/ and run nitrogen link again.
Reference #
- Designing for iPhone Duo — HIG
- Preparing your app for iPhone Duo — technology overview
Licence #
MIT — see LICENSE.

