Skip to content

· 13 min read · Nadeera Silva

Mobile

iPhone Duo Flutter Guide: Fold, Crease and Letterboxing

How Flutter apps behave on iPhone Duo, why they get letterboxed, and how to keep content out of the fold with Apple's reserved regions API.

Illustration of an open book-style foldable phone with teal interface cards flowing across both halves, an amber band marking the fold region and amber-outlined reserved zones near the corners, with layout-grid tiles, resize arrows and a closed folded phone nearby, standing for adapting Flutter layouts to iPhone Duo.

iPhone Duo went on sale on 23 October 2026, so if you ship a Flutter app on iOS, it is already running on Apple's first foldable. How it looks depends mostly on the Xcode version you build with, and the one thing Flutter can't handle for you yet is the fold in the middle of the inner display. This iPhone Duo Flutter guide covers what Apple changes per SDK, what Flutter gives you today, a tested Dart layout, a small Swift bridge to Apple's reserved regions API, and how I test it in the simulator.

How apps on iPhone Duo are laid out, by SDK

Apple's Prepare page is clear that every app runs on iPhone Duo, but the SDK your binary links against decides how much of the screen it gets. For a Flutter app that means the Xcode version on the machine (or CI runner) that builds the Runner target.

Built withInner display, openWhat it means for a Flutter app
iOS 26 SDK or earlier (Xcode 26)App sits in the center with empty space around the windowLetterboxed. Nothing in Dart fixes this; only a rebuild does
iOS 27 SDKFills most of the inner display but stays left of the status bar on the right edgeBetter, but you lose a strip on one side
iOS 27.1 SDK or later (Xcode 27.1)Full display; system toolbars and tab bars move to a vertical strip below the status barFull screen. Your own bars must respect per-edge safe area padding

On the outer display, Apple says an app built with the iOS 26 SDK shows its content to the left of the status bar and camera. Apple also notes in Preparing your app for iPhone Duo that builds from Xcode 26 and earlier don't extend under the status bar and camera.

Four schematic iPhone Duo inner displays: an iOS 26 SDK build shown as a centered portrait window with empty space on both sides, an iOS 27 SDK build filling the display except a status bar strip on the right, an iOS 27.1 SDK build filling the whole display with the status bar and vertical bars overlaid on the right edge, and a partially folded 27.1 build split into two panes by an amber fold band in the center
How the layout area changes with the SDK you build with, and how an active fold splits it

Why Flutter bars don't move to the side on their own

Apple's automatic vertical bars apply to system components such as UITabBarController and UINavigationController. Flutter draws its own NavigationBar, AppBar and Cupertino bars, so on a 27.1 build they stay where you put them. What you do get is the system strip reported as safe area padding. The author of flutter/flutter#192515 notes that this asymmetric safe area comes through correctly per edge in MediaQuery.paddingOf.

That makes per-edge handling the first fix. Apple's Prepare your app for iPhone Duo talk warns against assuming insets on opposite sides are equal, and the Flutter equivalent of that bug is code like MediaQuery.paddingOf(context).left * 2. Use SafeArea, or read left and right separately.

What to change in your Dart code first

Most of the work is the same work that makes an app good on tablets and in resizable windows. Apple's Prepare page lists the iOS patterns that break; these are the Flutter versions I look for:

  • Size from constraints, not the screen. Use LayoutBuilder or MediaQuery.sizeOf(context) and re-read on every build. A fold or unfold can resize the app mid-session.
  • No device or model checks. Hardcoded heights or lists of known iPhone sizes won't match iPhone Duo. Pick layouts from available width.
  • Don't treat a wide layout as "tablet only". Apple says the inner display has regular width and height size classes, like an iPad. If you hide a two-pane layout behind an iPad check, iPhone Duo users get a stretched phone layout instead.
  • Don't count on an orientation lock. In the same talk Apple says the inner display doesn't honor your supported interface orientations, so SystemChrome.setPreferredOrientations won't keep you in portrait there.
  • Check full-bleed images. BoxFit.cover heroes can crop important content on the wider inner display.

The fold (crease) region: what Apple calls a division

The visible crease is the hardware side of it. In the API, the fold is a division reserved region. The HIG page for iPhone Duo says that when the device is partially open, the folding region divides the inner display into usable regions and excludes the area at the center. The inner FaceTime camera is an occlusion region, present only while the camera is active.

Apple's UIView.ReservedRegion documentation gives the rules that matter for layout:

  • Read regions with reservedRegions(kind:options:). By default you only get active regions.
  • A division is inactive when iPhone Duo is fully open. Pass .includeInactive to get it anyway, and check isActive.
  • Frames are in the view's own coordinates, in points, and already include the region's margins.
  • UIKit tracks the regions you read in updateConstraints() and calls it again when they change.

Two details are worth knowing before you code against this. First, the docs disagree on the flat fold's size. The Strike a pose talk says it is inactive with zero width when flat, while the reference page says an inactive region still has a frame you can leave room for. The author of PR #193025 measured a 40pt division that kept its frame while inactive on the iOS 27.1 simulator. So decide with isActive, not with the width. Second, the same PR found that a region's active state lags the hinge by up to about a second on the simulator, which is why I read regions from layout rather than from a hinge callback.

Where Flutter stands on iPhone Duo today

MediaQuery.displayFeaturesOf(context) is Flutter's fold API, and the DisplayFeature docs still say it is populated only on Android. On iPhone Duo it returns an empty list.

  • #192515 proposes filling displayFeatures on iOS from the reserved regions. It is open, P2 and assigned.
  • PR #193025 implements it and is still open as of 26 October. Its code sits behind an iOS 27.1 SDK guard, and a reviewer points out that Flutter's CI builds the shipped engine with Xcode 26.2, so it would populate nothing until CI moves.
  • #193208 reports that a focused TextField loses its caret after folding or unfolding. It is open, and a maintainer expects a text input fix (#191781) to help.

When iOS does start reporting the fold, dialogs and sheets will behave like they do on Android foldables. A commenter on #192515 measured an AlertDialog going from 800 to 380 logical pixels wide once a 40pt fold was reported, because DisplayFeatureSubScreen keeps it on one side.

A Dart layout that avoids the fold

This is the helper I use. It reads Flutter's display features first, then falls back to regions pushed from Swift. I ran flutter analyze and widget tests on it with Flutter 3.47.7, including a simulated 40pt division on a 951x669 view, the inner display size measured in PR #193025.

import 'dart:ui' as ui;

import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';

/// A reserved region reported by UIKit on iOS 27.1 or later.
@immutable
class ReservedRegionInfo {
  const ReservedRegionInfo({
    required this.kind,
    required this.rect,
    required this.isActive,
  });

  factory ReservedRegionInfo.fromMap(Map<Object?, Object?> map) {
    double read(String key) => (map[key]! as num).toDouble();
    return ReservedRegionInfo(
      kind: map['kind']! as String,
      rect: Rect.fromLTWH(read('x'), read('y'), read('width'), read('height')),
      isActive: map['isActive']! as bool,
    );
  }

  /// 'division' (the fold) or 'occlusion' (a camera).
  final String kind;

  /// In logical pixels, relative to the Flutter view.
  final Rect rect;

  final bool isActive;
}

/// Holds the latest reserved regions pushed from the iOS side.
class ReservedRegions extends ValueNotifier<List<ReservedRegionInfo>> {
  ReservedRegions._() : super(const <ReservedRegionInfo>[]) {
    _channel.setMethodCallHandler((MethodCall call) async {
      if (call.method == 'regionsChanged') {
        value = _parse(call.arguments);
      }
    });
  }

  static final ReservedRegions instance = ReservedRegions._();

  static const MethodChannel _channel = MethodChannel(
    'com.example.app/reserved_regions',
  );

  /// Asks for the current regions once, for example at startup.
  Future<void> refresh() async {
    try {
      value = _parse(await _channel.invokeMethod<Object?>('getRegions'));
    } on MissingPluginException {
      // Android, web, or an iOS build without the native side.
      value = const <ReservedRegionInfo>[];
    }
  }

  static List<ReservedRegionInfo> _parse(Object? raw) {
    if (raw is! List<Object?>) {
      return const <ReservedRegionInfo>[];
    }
    return <ReservedRegionInfo>[
      for (final Object? item in raw)
        if (item is Map<Object?, Object?>) ReservedRegionInfo.fromMap(item),
    ];
  }
}

/// Returns the fold that content should avoid right now, or null.
Rect? activeFold(BuildContext context, List<ReservedRegionInfo> iosRegions) {
  for (final ui.DisplayFeature feature in MediaQuery.displayFeaturesOf(
    context,
  )) {
    final bool isFoldOrHinge = feature.type != ui.DisplayFeatureType.cutout;
    final bool splitsScreen =
        feature.type == ui.DisplayFeatureType.hinge ||
        feature.state == ui.DisplayFeatureState.postureHalfOpened;
    if (isFoldOrHinge && splitsScreen && feature.bounds.shortestSide > 0) {
      return feature.bounds;
    }
  }
  for (final ReservedRegionInfo region in iosRegions) {
    if (region.kind == 'division' && region.isActive && !region.rect.isEmpty) {
      return region.rect;
    }
  }
  return null;
}

The widget splits at the fold when one is active, splits by width when the inner display is flat, and shows one pane on the outer display. Apple's ReservedRegion docs say to compare the division's width and height to tell a book fold from a laptop fold, which is what the first two branches do.

class FoldAwareTwoPane extends StatelessWidget {
  const FoldAwareTwoPane({
    super.key,
    required this.start,
    required this.end,
    this.twoPaneMinWidth = 600,
  });

  final Widget start;
  final Widget end;
  final double twoPaneMinWidth;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<List<ReservedRegionInfo>>(
      valueListenable: ReservedRegions.instance,
      builder: (BuildContext context, List<ReservedRegionInfo> regions, _) {
        return LayoutBuilder(
          builder: (BuildContext context, BoxConstraints constraints) {
            final Rect? fold = activeFold(context, regions);

            if (fold != null && fold.height >= fold.width) {
              // Folded like a book: the fold runs top to bottom.
              return Row(
                children: <Widget>[
                  SizedBox(
                    width: fold.left,
                    child: SafeArea(right: false, child: start),
                  ),
                  SizedBox(width: fold.width),
                  Expanded(child: SafeArea(left: false, child: end)),
                ],
              );
            }

            if (fold != null) {
              // Folded like a laptop: the fold runs left to right.
              return Column(
                children: <Widget>[
                  SizedBox(
                    height: fold.top,
                    child: SafeArea(bottom: false, child: start),
                  ),
                  SizedBox(height: fold.height),
                  Expanded(child: SafeArea(top: false, child: end)),
                ],
              );
            }

            if (constraints.maxWidth >= twoPaneMinWidth) {
              // Flat inner display: no fold to avoid, so split by width.
              return Row(
                children: <Widget>[
                  Expanded(child: SafeArea(right: false, child: start)),
                  Expanded(child: SafeArea(left: false, child: end)),
                ],
              );
            }

            // Outer display or a narrow window: one pane.
            return SafeArea(child: start);
          },
        );
      },
    );
  }
}

Call ReservedRegions.instance.refresh() once in main() after WidgetsFlutterBinding.ensureInitialized(). The 600 breakpoint is my choice for list and detail screens; tune it for your content.

The Swift side: a platform channel to reserved regions

The bridge is a FlutterViewController subclass with an invisible view laid over the Flutter view. That view reads regions inside updateConstraints(), so UIKit calls it again whenever they change, and it pushes the new list to Dart. Set DuoFlutterViewController as the custom class of the Flutter View Controller in Main.storyboard. Subclassing the view controller is also the approach Flutter's UIScene guide suggests when you need it, so it fits a migrated app.

import Flutter
import UIKit

class DuoFlutterViewController: FlutterViewController {
  private let tracker = ReservedRegionTracker()

  override func viewDidLoad() {
    super.viewDidLoad()

    let channel = FlutterMethodChannel(
      name: "com.example.app/reserved_regions",
      binaryMessenger: binaryMessenger
    )
    channel.setMethodCallHandler { [weak self] call, result in
      guard call.method == "getRegions", let self else {
        result(FlutterMethodNotImplemented)
        return
      }
      result(self.tracker.currentRegions())
    }
    tracker.onChange = { [weak channel] regions in
      channel?.invokeMethod("regionsChanged", arguments: regions)
    }

    // Covers the Flutter view, so frames come back in Flutter's coordinates.
    tracker.translatesAutoresizingMaskIntoConstraints = false
    tracker.isUserInteractionEnabled = false
    view.addSubview(tracker)
    NSLayoutConstraint.activate([
      tracker.leadingAnchor.constraint(equalTo: view.leadingAnchor),
      tracker.trailingAnchor.constraint(equalTo: view.trailingAnchor),
      tracker.topAnchor.constraint(equalTo: view.topAnchor),
      tracker.bottomAnchor.constraint(equalTo: view.bottomAnchor),
    ])
  }
}

final class ReservedRegionTracker: UIView {
  var onChange: (([[String: Any]]) -> Void)?
  private var lastSent: NSArray = []

  override class var requiresConstraintBasedLayout: Bool { true }

  // UIKit tracks the regions read here and calls this again when they change.
  override func updateConstraints() {
    let regions = currentRegions()
    if !lastSent.isEqual(to: regions) {
      lastSent = regions as NSArray
      onChange?(regions)
    }
    super.updateConstraints()
  }

  func currentRegions() -> [[String: Any]] {
    guard #available(iOS 27.1, *) else { return [] }
    let kinds: [(UIView.ReservedRegion.Kind, String)] = [
      (.division, "division"),
      (.occlusion, "occlusion"),
    ]
    return kinds.flatMap { kind, name in
      reservedRegions(kind: kind, options: .includeInactive).map { region in
        [
          "kind": name,
          "x": Double(region.frame.minX),
          "y": Double(region.frame.minY),
          "width": Double(region.frame.width),
          "height": Double(region.frame.height),
          "isActive": region.isActive,
        ]
      }
    }
  }
}

The Swift uses only names from Apple's UIKit reference: reservedRegions(kind:options:), UIView.ReservedRegion.Kind.division and .occlusion, QueryOptions.includeInactive, frame and isActive, all marked iOS 27.1.

If you only need the hinge angle for an effect, Apple has a separate API (UIHingeInteraction, covered in Leverage multiple displays and scenes on iPhone Duo), but Apple says to use region and arrangement APIs for layout, not hinge data.

Testing a Flutter app on iPhone Duo

Apple recommends the iPhone Duo simulator in Xcode 27.1 as the best way to test the full experience. Open Device Hub, run the app, and use the controls to open, close, rotate and fold. My checklist:

  • Build with Xcode 27.1 and confirm the app is no longer letterboxed on the inner display.
  • Check every screen in each pose: closed, open, folded like a book, folded like a laptop, and rotated.
  • Look at the side strip on a 27.1 build. Nothing you can tap should sit under the status bar.
  • Fold with a TextField focused and check the caret (#193208).
  • Fold on screens with dialogs, bottom sheets and grids, and look for buttons sitting in the crease.
  • Try Split View on the inner display, with your app on the left and then on the right.
  • If you're cutting a new release anyway, make sure it's on Flutter 3.47.8 for the Android TLS fix.

When the app looks right, Apple's Prepare page says that submitting iPhone Duo screenshots in App Store Connect adds a badge to your product page showing the app is optimized for iPhone Duo.

Frequently asked questions

Do Flutter apps work on iPhone Duo?

Yes. Apple says every app runs on iPhone Duo, but the SDK you build with decides how much of the screen it gets. Flutter apps built with Xcode 27.1 and the iOS 27.1 SDK use the full display and resize as the device opens and closes.

Why is my Flutter app letterboxed on iPhone Duo?

It was built with the iOS 26 SDK or earlier, so iOS shows it in the center of the inner display with empty space around it. Rebuild and ship with Xcode 27.1, after migrating to the UIScene lifecycle that Xcode 27 requires.

How do I keep buttons out of the iPhone Duo crease?

The crease is the fold, which iOS 27.1 reports as a division reserved region while the device is partially folded. Flutter does not expose it on iOS yet, so read it through a small platform channel and lay out content on each side of the fold, leaving the fold itself empty.

Does MediaQuery.displayFeatures work on iPhone Duo?

Not yet. Flutter documents display features as populated only on Android, so the list is empty on iPhone Duo. An open Flutter pull request would fill it from the iOS reserved regions, but it has not landed.

Can I lock my Flutter app to portrait on iPhone Duo?

Only partly. Apple says the inner display does not honor your supported interface orientations, so a portrait lock will not stop your app from getting a wide layout when the device is open. Build layouts from the available width instead.

How do I test a Flutter app for iPhone Duo without the device?

Use the iPhone Duo simulator in Xcode 27.1, which Apple calls the best way to test the full experience. Device Hub has controls to open, close, rotate and fold it, so you can check every pose.

Sources

  1. Three steps to make your app shine on iPhone Duo (Apple Developer)
  2. Get ready for iPhone Duo (Apple Developer)
  3. Preparing your app for iPhone Duo (Apple Developer Documentation)
  4. Designing for iPhone Duo (Human Interface Guidelines) (Apple Developer, 2026-09-09)
  5. UIView.ReservedRegion (Apple Developer Documentation)
  6. reservedRegions(kind:options:) (Apple Developer Documentation)
  7. Prepare your app for iPhone Duo (Tech Talk) (Apple Developer)
  8. Strike a pose with adaptive layouts on iPhone Duo (Tech Talk) (Apple Developer)
  9. Leverage multiple displays and scenes on iPhone Duo (Tech Talk) (Apple Developer)
  10. iPhone Duo arrives at Apple Store locations worldwide (Apple Newsroom, 2026-10-23)
  11. [iPhone Duo] Proposal: populate MediaQuery.displayFeatures on iOS (#192515) (flutter/flutter on GitHub, 2026-09-09)
  12. [iOS] Populate display features from the iPhone Duo hinge and reserved regions (PR #193025) (flutter/flutter on GitHub, 2026-09-18)
  13. [iPhone Duo] Text field caret disappears after folding/unfolding (#193208) (flutter/flutter on GitHub, 2026-09-23)
  14. DisplayFeature class (dart:ui) (Flutter API docs)
SharePost on XLinkedIn

Comments

Share a question or note about this article.

Loading comments…