The first foldable iPhone is here, but getting your Flutter app ready takes more than just rebuilding it in the latest Xcode. From adapting layouts to handling the fold, there are a few things you'll need to take care of yourself.
In this guide, we'll show you how to do it using Flutter and duo_kit, our open-source package for supporting foldables on both iOS and Android.
iPhone Duo, the first foldable iPhone, went on sale on October 23, 2026. A Flutter app not adapted for it either runs in a phone-sized window with black bars around it or stretches a phone layout across an almost square display, depending on the Xcode that built it. Fold the device half open and the crease runs through the middle of your list, with a dialog sitting on the hinge.
Apple's guidelines say that an app built with standard system components and designed to resize "automatically adapts to the device's poses with little adjustment required". Flutter draws its own widgets, so that promise does not cover your app. Foldables are still a niche market, so supporting them needs to be cost-effective. Below, we show how to build an iPhone Duo app that meets Apple’s expectations using Flutter and duo_kit, an open-source package we built at LeanCode to support foldables on both iOS and Android. We also cover the gaps Flutter has yet to address on iOS.
Starting April 2027, every app submitted to the App Store needs iPhone Duo screenshots, and submitting them now adds a badge to the App Store page showing the app is optimized for iPhone Duo.
In short:
iPhone Duo has two displays. The outer display is wider and shorter than any other iPhone screen. The inner display is a tablet-like, 1.4:1 display, and people use it in landscape or portrait, flat, or half-open like a book or a laptop. Designing for iPhone Duo in Apple's Human Interface Guidelines describes how apps should behave in each pose, and the images in this section come from it.
Device poses. Image: Apple.
Three things change compared with a regular iPhone.
Navigation moves to the side. On the outer display and inner display in landscape, the system stacks the status bar and app controls along one edge to maximize content height. The inner display in portrait keeps horizontal bars. Apple's advice: "In general, don't override the default bar placement." For a Flutter app, this means your bottom navigation belongs in that strip, and ideally the back button and app bar actions too.
Controls on the vertical axis. Image: Apple.
The fold splits the window. When the device is half open, a folding region runs across the inner display. Nothing you tap should sit on it.
Reserved regions of the inner display. Image: Apple.
A list and its details belong on either side, as Notes evens out its two panes when the device folds. Apple's alerts and sheets move off the fold by themselves.
Notes, open and half open. Image: Apple.
The state stays. Apple asks you to "keep functionality and the state of elements the same between displays" and "don't reinvent your app when it resizes." Moving between poses must not reset a scroll position or clear a half-written message.
Apple's tech talks go into more detail. Their examples use SwiftUI and UIKit, but the patterns apply to Flutter:
How much of the screen your app gets depends on the Xcode you build it with:
Schematic of an app that was not adapted by the Xcode it was built with, based on Apple's tech talk.
Built with Xcode 26 or earlier, the app runs in what we call compatibility mode. In Apple's Preparing your app for iPhone Duo, "your app doesn't extend under the status bar and camera," and on the inner display it stays phone-sized. Xcode 27.0 extends the app across the display, up to the status bar but not under it. Xcode 27.1 gives it the whole screen, with the strip as padding on one side.
The tech talk Prepare your app for iPhone Duo shows all three. An unadapted Flutter app doesn't know what to do with that padding: content either runs under the status bar and camera or leaves an empty column next to them, while the navigation stays at the bottom.
Before any layout work, get the app to build and run in the right mode:
flutter run and flutter build ios migrate an unmodified AppDelegate by themselves. A customized one needs the steps from Flutter's UIScene adoption guide: make your AppDelegate a FlutterImplicitEngineDelegate, move plugin registration, method channels and platform views from didFinishLaunchingWithOptions to didInitializeImplicitFlutterEngine, and add a UIApplicationSceneManifest with FlutterSceneDelegate to Info.plist. UIKit then stops calling UI lifecycle methods such as applicationDidBecomeActive on the app delegate, so check plugins that hook into the app lifecycle, such as push notifications and deep links.platform :ios, '15.0'
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 15.0
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '15.0'
end
end
end
endUISupportedInterfaceOrientations changes nothing there on its own. Combined with UIRequiresFullScreen, which many apps set only to skip multitasking on iPad, it makes iOS scale the app into a portrait window and report no fold. On the simulator, the fold stayed missing for that bundle ID even after we removed the lock, until we rebooted the simulator. A lock set in code with SystemChrome.setPreferredOrientations keeps the fold, but turns your UI on its side when the device opens in landscape.For Flutter, opening, closing or rotating the device is a resize. The window gets a new size and new insets, and the app rebuilds with a new MediaQuery. A layout built on MediaQuery.sizeOf and LayoutBuilder follows along. A layout that caches the screen size, or locks the orientation with SystemChrome, does not. Folding the inner display half open is different: the window keeps its size and insets, and only the fold changes.
Two parts of MediaQuery describe the device:
viewPadding holds the strip. On iOS, the edge with the status bar and camera comes as an inset along that whole side, so a SafeArea keeps your content clear of them.displayFeatures holds the fold and the camera cutouts, each with its bounds and, for a fold, its posture.Flutter uses the display features itself. showDialog, showModalBottomSheet, and other routes take an anchorPoint, and DisplayFeatureSubScreen opens the route on the side of the fold closest to that point. On Android foldables, all of this works today.
On iPhone Duo, MediaQuery.displayFeaturesOf(context) is empty in every pose: Flutter fills it only on Android. As of October 28, 2026, the proposal to change that, flutter/flutter#192515, is open. Its fix, #193025, is in review and waits for Flutter's CI to move to Xcode 27.1.
Until it lands, a bridge plugin has to read the reserved regions natively and publish them. We use foldable, set up as its README shows. With the fold in `MediaQuery`, Flutter keeps dialogs, sheets and popups of a half-open device in one half, as it does on Android. Mind two gaps: while the device is closed, foldable publishes nothing, so your layout does not know where the camera and the status cluster of the cover display are (the status bar items grouped around the camera), and on the simulator the fold arrives about a second after the device comes to rest half open.
Two things to know:
displayFeatures. Publish them yourself from the reserved regions foldable reports, or navigation in the strip can end up under the camera in landscape.Where the geometry comes from.
duo_kit reads only MediaQuery. When Flutter fills the display features on iOS, you remove the bridge and keep your layout code.
Getting the geometry is the easy half. The cases below come up in any Flutter app on iPhone Duo. Each has a fix in plain Flutter, which we give with the case; duo_kit packages those fixes, and the section on duo_kit shows how.
State across poses. The obvious way to add a second pane is to switch on the fold:
import 'dart:ui' show DisplayFeatureType;
final hasFold = MediaQuery.displayFeaturesOf(context)
.any((feature) => feature.type == DisplayFeatureType.fold);
return hasFold
? Row(children: [Expanded(child: list), Expanded(child: details)])
: list;When the device folds, a Row replaces the list in the tree. Flutter throws away the old list with its State and builds a new one, so the scroll position and any typed text are lost. The same happens to a whole page when the navigation moves from the bottom into the strip and the page ends up under a different parent. The plain fix is a GlobalKey on each pane, so that Flutter moves its State to the new parent instead of dropping it.
Why a plain switch loses state.
The keyboard. In the laptop pose, the keyboard opens in the lower half. If the whole screen makes room for it, the upper half jumps for no reason. Only the pane below the fold should resize. Flutter has a helper for this: MediaQueryData.removeDisplayFeatures trims the padding and the keyboard inset to the part of the window a pane covers. It works only if nothing above the panes makes room for the keyboard first; a Scaffold does that by default, so set resizeToAvoidBottomInset: false on it.
Dialogs. A dialog opened while the device lies flat should move to one half once the device folds. Which half matters too: content to read fits above a horizontal fold, and controls fit below it, within reach. Pass an anchorPoint in the half you want, such as Offset(double.maxFinite, 0) for the top, and Flutter keeps the route there.
Things that look like a fold or a strip. A fold at the edge of a window in Split View splits nothing. An Android phone's camera cutout in landscape creates the same one-sided inset as a strip. A camera from the previous pose can linger for a moment after the device moves.
Right-to-left languages. The first pane and the dialogs swap sides. The strip does not, since it follows the hardware. More on right-to-left layouts in Flutter.
Liquid Glass is a material that UIKit and SwiftUI render. Flutter draws every pixel itself, so its widgets can only imitate it. The request, flutter/flutter#170310, has been open since June 2025.
You can get the real material today by embedding native UIKit views as platform views. adaptive_platform_ui does that for toolbars and tab bars. On iPhone Duo, it lays out its own toolbar in the strip, also in the left pane of Split View, and moves items that do not fit into an overflow menu. The cost is performance: each native control is a platform view, which costs more to compose than a Flutter widget, most visibly over scrolling content. We have not measured it for this package.
Packages that draw glass with shaders and BackdropFilter stay inside Flutter and avoid platform views, and what they draw imitates Apple's material. The same goes for the Liquid Glass widgets merged into `cupertino_ui on October 20, 2026, which are not in a release yet as of version 1.1.4.
The two packages overlap only in the navigation. If your bars should be native Liquid Glass, take adaptive_platform_ui: on iPhone Duo it puts them into the strip. If your app has its own design or uses Material on iOS, duo_kit's FoldScaffold places your own navigation there. Two panes and dialogs at the fold are a separate job: FoldSplitView and FoldAnchor do not need duo_kit's scaffold, so they work with either. Other packages on pub.dev take on iPhone Duo too, such as iphone_duo_ui_pro and nitro_fold_duo.
duo_kit is at version 0.1, and it gives iPhone Duo developers the closest Flutter pieces to Apple's building blocks:
| Apple (UIKit / SwiftUI) | duo_kit | What it does |
|---|---|---|
| Bars on the vertical axis | FoldScaffold | Places your navigation in the strip or at the bottom; drawing it stays yours |
| Arrangement view, split | FoldSplitView | Puts two panes on either side of the fold and keeps their state |
| Alerts and sheets that avoid the fold | FoldAnchor | Picks the anchorPoint; Flutter moves the route |
| Reserved regions | FoldGeometry | Reads the strip, the fold and the cutouts from MediaQuery |
flutter pub add duo_kit foldable
duo_kit needs Flutter 3.41 or later. It needs no setup of its own: once foldable is set up as its README shows, the widgets below read the fold and the strip from MediaQuery.
import 'package:duo_kit/duo_kit.dart';
FoldScaffold(
body: page,
navigationBuilder: (context, placement) => MyNavigationBar(
// A row at the bottom, a column in the strip.
axis: placement.axis,
),
)The navigation gets the padding of the edge it sits on: the bottom inset, or, in the strip, the room taken by the camera and the status cluster. A SafeArea inside your navigation widget keeps its items clear of it. The body loses the padding on the navigation's edge and stays the same widget in every pose, so it keeps its state. Wrap the body in a SafeArea unless it is a Material Scaffold, which pads itself.
FoldSplitView(
first: list,
second: details,
unfoldedBuilder: (context, first, second) => LayoutBuilder(
builder: (context, constraints) => constraints.maxWidth >= 600
? Row(
children: [
SizedBox(width: 320, child: first),
Expanded(child: second),
],
)
: Stack(
fit: StackFit.expand,
children: [
first,
Offstage(child: TickerMode(enabled: false, child: second)),
],
),
),
)When a fold crosses it, FoldSplitView puts first on the leading side of a vertical fold (or above a horizontal one) and second on the other side. Without a fold it uses unfoldedBuilder, so decide there whether two panes fit: a switch around FoldSplitView is the plain switch from above. Hide a pane you leave out with Offstage, so that it keeps its state. Each pane carries a GlobalKey and keeps only the padding and keyboard inset of the edges it touches, and FoldSplitView also works below a top bar or in a PageView.
showDialog(
context: context,
anchorPoint: FoldAnchor.content.resolvePoint(context),
builder: (context) => const MyDialog(),
);FoldAnchor picks the point: content is the top trailing corner of the window and controls the bottom trailing one. So content opens above a horizontal fold and controls below it, and across a vertical fold both open on the trailing side. Flutter's DisplayFeatureSubScreen does the rest, and since the route keeps its anchor, a dialog opened while the device lies flat moves to its half once the device folds.
import 'package:duo_kit/debug.dart';
import 'package:flutter/foundation.dart';
kDebugMode ? FoldDebugTools(child: app) : appFoldDebugTools paints the geometry over your app and adds a button at the left edge: tap it to switch between simulated poses, hold it to hide or show the overlay. Put it inside FoldableProvider, so that the simulated fold replaces the real one.
For widget and golden tests, FoldTestPose holds window metrics we measured on the iPhone Duo simulator (iOS 27.1). Size the test window to the pose and run the test as iOS, where duo_kit looks for the strip:
import 'package:duo_kit/testing.dart';
testWidgets(
'splits at the fold',
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
(tester) async {
final data = FoldTestPose.duoHalfOpenedLandscape.data;
tester.view
..physicalSize = data.size
..devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(MediaQuery(data: data, child: screen));
// FoldSplitView measures where it sits one frame later.
await tester.pump();
},
);As you can see, adapting a Flutter app for iPhone Duo makes a real difference. And with duo_kit, you can bring the same foldable-friendly experience to Android without building separate solutions for each platform in a cost-effective way.
Some gaps remain. On the left side of Split View, a Flutter app gets no strip because the status bar and camera sit on the other side and Flutter does not read UIKit's verticalBarEdge trait, so the navigation stays at the bottom there. We have tested duo_kit on the iPhone Duo simulator only so far.
We've made duo_kit open source, and we'd love to see the Flutter community help us improve it. Check it out on GitHub or pub.dev. Issues and pull requests are always welcome.
If you want your Flutter app ready for iPhone Duo ahead of Apple's April 2027 screenshot requirement, get an estimate from the team behind duo_kit.

In this article, we’re sharing LeanCode’s 24 practical Flutter and Dart patterns that help you write less boilerplate, make your code cleaner, and catch mistakes earlier. Apply these patterns and you'll find yourself coding faster, communicating more clearly with your teammates, and spending less time debugging issues that the compiler could have caught.

If you’ve ever worked with tools like Cursor Browser or Playwright MCP, you know how useful it is to have an AI agent interact directly with a running app, and if you’re building with Flutter, you’ve probably wished for something similar, and here it is - our Marionette MCP!

We introduce Patrol MCP! A new way of working with Patrol by enabling AI agents to write, run, and debug E2E tests using the patrol develop command. By bridging the gap between AI and Patrol test execution, it speeds up test development and unlocks a more autonomous testing workflow.