Flutter enters this autumn in an unusual position. Of the major cross-platform frameworks it is the best prepared for iOS 27 and has the most frustrating gap on iPhone Duo.
- iOS 27: apps built with the iOS 27 SDK must use UIKit’s scene-based life cycle or they fail to launch. Flutter already ships that support, and migrates projects with an unmodified app delegate automatically.
- iPhone Duo: Flutter already has a well-designed API for foldable screens, built for Android — and on iOS it is always empty.
This is a playbook for both. Status is as of the date at the top; the linked Flutter issues are active.
Part 1: iOS 27 and the scene life cycle
| Stack | Status | What it means for you |
|---|---|---|
| UIKit / SwiftUI (native) | You migrate | Adopt a scene manifest and a window scene delegate. SwiftUI apps using the App protocol already use scenes.TN3187 |
| Flutter | Shipped in 3.41 | Default since 3.41; unmodified AppDelegates migrate automatically on build. Custom AppDelegates, and older Flutter versions, migrate by hand.Flutter migration guide |
| React Native | 0.88 release candidate | Scene life cycle support, including linking, is in the 0.88 release candidates — 0.88.0-rc.1 is current — and in no stable release. 0.88 stable is scheduled for 12 October. On 0.87 or earlier, add a SceneDelegate to your iOS project yourself.Commit · 0.88 on npm · Release schedule · Template PR for 0.87 |
| Expo | Shipped, opt-in on 57 | SDK 58 beta uses scenes by default. On SDK 57 the runtime shipped too, but you opt in: set ios.enableSceneSupport in expo-build-properties and run expo 57.0.23 or newer. SDK 56 still fails to launch under Xcode 27.SDK 58 beta · enableSceneSupport · SDK 57 runtime · Original issue |
Flutter’s documentation is direct about the requirement: “Flutter apps built with Xcode 27 that do not adopt UIScene fail to launch on startup.” It is equally direct about the fix: as of Flutter 3.41, UIScene support is the default, and eligible apps are migrated automatically.
Check whether you were migrated
The migration runs when you build. Run flutter build ios or flutter run on Flutter 3.41 or later. Per the docs, a successful migration prints:
Finished migration to UIScene lifecycle
If the CLI can’t migrate your project, it warns you and gives manual instructions. The usual reason is an app delegate that has been customised — common in production apps that register method channels, set up push notifications or integrate native SDKs.
Manual migration for a customised app delegate
These steps are from Flutter’s official UIScene migration guide.
1. Register plugins in the new implicit-engine callback. Plugin registration moves out of didFinishLaunchingWithOptions:
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
2. Move method channels and platform views there too. Create them with the engine bridge’s messenger:
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
let batteryChannel = FlutterMethodChannel(
name: "samples.flutter.dev/battery",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
)
}
The guide warns that reading the FlutterViewController from the window in didFinishLaunchingWithOptions “might crash” your app. That’s a common pattern in older channel setup code, so search for it.
3. Declare the scene in Info.plist, using Flutter’s scene delegate:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
4. Move UI life-cycle logic out of the app delegate. Once migrated, UIKit stops calling app-delegate methods about UI state, such as applicationDidBecomeActive. If you need a scene delegate of your own, Flutter requires you to subclass FlutterSceneDelegate or conform to FlutterSceneLifeCycleProvider:
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {}
Then set UISceneDelegateClassName in Info.plist to your class instead of FlutterSceneDelegate.
Don’t forget your plugins
Your app is only as ready as its plugins. Flutter’s guide has a separate section for plugins that use iOS application life-cycle events. Upgrade plugins before the migration, not after, and treat any plugin that hooks app-delegate callbacks — notifications, deep links, attribution, background tasks — as something to test explicitly on an iOS 27 build.
Part 2: iPhone Duo layout
When iPhone Duo opens, closes or rotates, the Flutter view resizes. MediaQuery updates, dependent widgets rebuild, and LayoutBuilder re-runs with new constraints. A Flutter app built on constraints adapts.
The problems are code that assumed the screen can’t change shape while running.
Trap 1: a size captured once
// Breaks on iPhone Duo — captured once, stale after the device unfolds
class _HomeState extends State<Home> {
late final double _width;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_width = MediaQuery.of(context).size.width; // throws on the second call, too
}
}
Read the size where you build, and depend only on what you use:
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
return width >= 600 ? const TwoColumnHome() : const SingleColumnHome();
}
MediaQuery.sizeOf rebuilds the widget only when the size changes, not on every MediaQuery change. Inside a subtree, prefer LayoutBuilder, which answers the question you actually care about: how much space this widget has.
Trap 2: orientation locks and OrientationBuilder
Apple states that iPhone Duo’s inner display doesn’t honor your supported interface orientations. A Flutter app that calls SystemChrome.setPreferredOrientations to stay in portrait can’t rely on that on the inner display.
OrientationBuilder doesn’t help either. It reports orientation from the aspect ratio of the available space, and iPhone Duo’s inner display is close to square. Branch on width, not orientation: a single width breakpoint covers the outer display, the inner display in either orientation, and Split View.
Trap 3: symmetric padding
With toolbars along one side of the display, safe-area insets on iPhone Duo are often asymmetric. Apple’s guidance is to handle each side independently.
final padding = MediaQuery.paddingOf(context);
// Wrong on iPhone Duo — assumes left and right match
Padding(padding: EdgeInsets.symmetric(horizontal: padding.left), child: content);
// Right — or simply wrap in SafeArea, which applies each side separately
Padding(padding: EdgeInsets.only(left: padding.left, right: padding.right), child: content);
Trap 4: navigation bars Flutter draws itself
On iPhone Duo the system moves its own tab bars and toolbars to the side of the outer display, and of the inner display in landscape.
This is our analysis. Flutter’s NavigationBar and BottomNavigationBar are drawn by Flutter, not UIKit, so iOS has no reason to move them. They will stay horizontal strips at the bottom of a wide display. If you want your app to match the platform, switch to a side layout yourself at wider widths — Material’s NavigationRail is the natural fit — and keep controls in the same relative positions across poses, as Apple’s guidelines ask.
Trap 5: full-screen 16:9 video
The numbers are more nuanced than “foldables mean black bars.” In landscape, 16:9 video wastes a similar share of iPhone Duo’s inner display as a normal iPhone — the bars just move to the top and bottom — and the picture is physically bigger. Portrait is where full-width 16:9 wastes most of the screen. There, a player in an AspectRatio(aspectRatio: 16 / 9) above a scrolling list uses the space well.
Part 3: the fold, and Flutter’s empty displayFeatures
Flutter has had foldable support since 2021: MediaQuery.displayFeatures describes hinges, folds and cutouts, and DisplayFeatureSubScreen keeps dialogs and sheets off them. On Android foldables it works.
On iOS it is empty. Flutter’s API documentation says so plainly: DisplayFeature “is populated only on Android.”
A community proposal to populate it for iPhone Duo, flutter#192515, is open and labelled P2 and team-ios, with no response from the team so far. Separately, flutter#192667 reports that inside a dialog confined to one half of a foldable, MediaQuery.sizeOf still reports the whole screen. That matters the moment display features are populated on any platform.
So today, a Flutter app on iPhone Duo resizes correctly but can’t tell where the fold is — including inside Flutter’s own dialog positioning.
A plugin that fills the gap without inventing an API
Most apps don’t need the fold. If yours does — split video calls, reading apps, games — the lowest-regret design is not a new API. Feed iOS 27.1’s reserved regions into the DisplayFeature model Flutter already has. Then DisplayFeatureSubScreen, and any code written for Android foldables, works unchanged. When Flutter populates it natively, you delete the plugin.
The Dart side uses only stable Flutter APIs. DisplayFeature has a public constructor, MediaQueryData.copyWith accepts displayFeatures, and DisplayFeatureType.fold is documented as a fold in a flexible screen without a physical gap:
import 'dart:ui' show DisplayFeature, DisplayFeatureState, DisplayFeatureType;
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
/// Merges iPhone Duo's fold into MediaQuery.displayFeatures for its subtree.
class DuoFoldScope extends StatefulWidget {
const DuoFoldScope({super.key, required this.child});
final Widget child;
@override
State<DuoFoldScope> createState() => _DuoFoldScopeState();
}
class _DuoFoldScopeState extends State<DuoFoldScope> {
static const _channel = EventChannel('duo/fold');
late final Stream<List<DisplayFeature>> _folds =
_channel.receiveBroadcastStream().map(_toFeatures);
@override
Widget build(BuildContext context) {
return StreamBuilder<List<DisplayFeature>>(
stream: _folds,
initialData: const [],
builder: (context, snapshot) {
final folds = snapshot.data ?? const [];
if (folds.isEmpty) return widget.child;
final data = MediaQuery.of(context);
return MediaQuery(
data: data.copyWith(displayFeatures: [...data.displayFeatures, ...folds]),
child: widget.child,
);
},
);
}
static List<DisplayFeature> _toFeatures(dynamic event) => [
for (final f in (event as List).cast<Map>())
DisplayFeature(
bounds: Rect.fromLTWH(
(f['x'] as num).toDouble(),
(f['y'] as num).toDouble(),
(f['width'] as num).toDouble(),
(f['height'] as num).toDouble(),
),
type: DisplayFeatureType.fold,
state: DisplayFeatureState.postureHalfOpened,
),
];
}
The native side reads the fold with the API Apple showed in its “Strike a pose” Tech Talk and streams the frames. iOS points and Flutter logical pixels are the same unit, so the frames can be used directly:
// NOT COMPILED: requires the iOS 27.1 SDK in Xcode 27.1, which is not yet
// released. API names are from Apple's Tech Talk sample code.
final class DuoFoldStreamHandler: NSObject, FlutterStreamHandler {
private var sink: FlutterEventSink?
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
sink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
sink = nil
return nil
}
/// Call when the Flutter view lays out.
func publishFolds(for flutterView: UIView) {
let frames = flutterView.reservedRegions(kind: .division).map(\.frame)
sink?(frames.map { ["x": $0.minX, "y": $0.minY, "width": $0.width, "height": $0.height] })
}
}
Register its FlutterEventChannel in didInitializeImplicitFlutterEngine using engineBridge.applicationRegistrar.messenger(), as in Part 1.
Three design notes:
- An empty list is the normal case. Apple says the fold’s region is only active when the device is folded, and has zero width when flat. Since only active regions are returned by default, “no features” means “no fold right now.”
- Use the hinge for effects, not layout. Apple’s scenes Tech Talk: hinge data “is ideal for driving interactions or effects. For layout, use the arrangement and region APIs.”
- Don’t ship it untested. You can now test it: the iPhone Duo simulator arrived in the Xcode 27.1 beta on 18 September 2026. Note that the simulator cannot run most app extensions and has no StandBy, so those still need hardware — see what the Xcode 27.1 beta can and cannot do.
A plan, in order
- Now: move to Flutter 3.41 or later, upgrade plugins, and build for iOS. Confirm the scene migration ran, or do it manually. Retest notifications, deep links and every plugin that touches app life-cycle events.
- Now: search for sizes captured in
initStateordidChangeDependencies,EdgeInsets.symmetricbuilt from one padding side, orientation locks andOrientationBuilder. Replace them withMediaQuery.sizeOf,LayoutBuilder, per-side padding and width breakpoints. - Now: decide how navigation should look at wide widths.
- Now, in the Xcode 27.1 beta: run the app on the iPhone Duo simulator in Device Hub, through every pose and Split View. The first boot takes several minutes; after that it behaves.
- Only if your product needs it: build the fold plugin, and watch flutter#192515 so you can delete it later.
Our rough planning estimate for an established app, not a quote: for a Flutter app with an unmodified app delegate, step 1 can be close to free; with a heavily customised one, budget days. Step 2 is typically days. A navigation redesign or the fold plugin is separate work worth scoping on its own.
For a per-issue reference of what breaks on iPhone Duo, see the Flutter guide on iphoneduosupport.com. For every other change in the iOS 27 SDK, see iOS 27 for developers, and for the toolchain itself, the Xcode 27.1 beta and its iPhone Duo simulator.