Window sizes in points, before insets (sizes 3, bars A)
| Pose | Window | Bars | Each leaf when folded |
| Closed, portrait | 466 × 678 | vertical, on the camera edge | — |
| Closed, landscape | 678 × 466 | vertical | — |
| Open, hinge horizontal | 669 × 951 | horizontal (top / bottom) | 669 wide × about 475 tall |
| Open, hinge vertical | 951 × 669 | vertical | about 475 wide × 669 tall |
2. The mental model changes
On Android, foldable support is usually posture-driven: observe WindowLayoutInfo, derive tabletop, book or flat, and pick a layout per posture. Apple asks for the opposite order:
- Size classes first. Two layouts, compact width for the outer display and regular width for the inner, cover every pose. "Don't reinvent your app when it resizes; allow the existing layout to expand."
- Reserved regions second. The fold and the cameras are areas your layout adapts around, like the window controls on iPad. System containers do this on their own; custom layouts query the regions.
- Displacement, not rearrangement. "Avoid extreme layout changes as people fold the device… favor small adjustments over rearrangement." Move a button, widen a gap, split a pane at the fold. Don't swap in a different screen. This is an architectural requirement, not a styling one; section 4 shows why.
- Same functions in every pose. Controls may overflow and content may move, but nothing may exist in one pose only.
- Bars are vertical. The single biggest visible difference. Standard bars move to the side for free; custom bars have to follow or justify why not.
What stays the same: the postures, the "content on the standing leaf, controls on the flat leaf" tabletop idea, the need to keep interactive elements away from the hinge, and the rule that continuous scrolling content such as articles and feeds may run across the fold.
3. Deriving posture, if you still need it
Most apps don't: size classes plus system containers cover them. Apps with a bespoke pose layout (games, media, creative tools with a tabletop mode) can derive the familiar Android postures from two inputs:
Pseudocode against tech-talk names · will not build until Xcode 27.1 beta ships
enum Posture: Equatable {
case compact // outer display, or a narrow Split View slot
case open // inner display, flat
case tabletop(CGRect) // inner display, horizontal fold; the division region's frame
case book(CGRect) // inner display, vertical fold
}
struct PostureReader<Content: View>: View {
@Environment(\.horizontalSizeClass) private var width
@ViewBuilder var content: (Posture) -> Content
var body: some View {
FoldReader { fold in // section 10: nil when flat, closed, or unsupported
let posture: Posture = switch (width, fold) {
case (.regular, let r?) where r.width > r.height: .tabletop(r)
case (.regular, let r?): .book(r)
case (.regular, nil): .open
default: .compact
}
content(posture)
}
}
}
Differences from FoldingFeature to remember:
- No
FLAT state with bounds. When the device is flat the division region is inactive and not returned unless you ask with .includeInactiveT. Ask only for decisions that must hold in every pose. Apple's example: prefer an even number of grid columns so the fold falls on a gutter.
- The hinge angle is a separate live stream. The HIG: hinge data is for driving interactions and effects; layout uses the region and arrangement APIs.
- A posture-specific layout is allowed, but "make sure functionality and general hierarchy is preserved", and the transition into it should be a displacement of what is already there, not a different screen. How to build that is the next section, and it is where a literal port goes wrong.
4. Architecture: one tree, posture as numbers
This is the part of the transition that is underestimated. "Displacement instead of rearrangement" reads like a layout preference; it is a constraint on how the view layer is structured.
The Android shape
A foldable Android app almost always has a posture state machine with a screen per state:
when (posture) { // Android, typical
is Posture.Tabletop -> TabletopScreen(posture.hinge)
is Posture.Book -> BookScreen(posture.hinge)
Posture.Flat -> FlatScreen()
}
Ported literally:
Pseudocode · the shape to avoid
switch posture { // same shape in SwiftUI: wrong result
case .tabletop(let fold): TabletopScreen(fold: fold)
case .book(let fold): BookScreen(fold: fold)
default: FlatScreen()
}
Each case is a different view identity. On every fold SwiftUI tears one subtree down and builds another: @State inside it resets, scroll positions jump, focus is lost, and the only animation possible is a crossfade between two screens, which is precisely the "extreme layout change" the HIG forbids. The architecture produces the violation before any design decision is made.
The shape the HIG asks for
One tree per size class, chosen once at the top. Below that branch, the tree has the same identity in every pose, and the posture changes only numbers: where a split sits, how wide a gap is, which alignment a control uses.
Pseudocode · builds today except for FoldReader, which needs the 27.1 beta
struct RootScreen: View {
@Environment(\.horizontalSizeClass) private var width
var body: some View {
if width == .compact { CompactScreen() } // outer display and Split View slots: one identity
else { RegularScreen() } // inner display, flat or folded, either hinge: one identity
}
}
struct RegularScreen: View {
@Environment(\.model) private var model // state lives above the layout
var body: some View {
FoldReader { fold in // CGRect? — nil when flat
GeometryReader { g in
let horizontalHinge = (fold?.width ?? 0) > (fold?.height ?? 0)
let layout = horizontalHinge
? AnyLayout(VStackLayout(spacing: fold?.height ?? 0))
: AnyLayout(HStackLayout(spacing: fold?.width ?? 0))
layout { // same children, same identity, animated frame changes
PrimaryPane(model)
.frame(height: horizontalHinge ? (fold?.minY ?? g.size.height / 2) : nil)
SecondaryPane(model)
}
.animation(.snappy, value: fold)
}
}
}
}
AnyLayout swaps the arrangement of the same children without changing their identity; ArrangementViewT does the same with system rules and system animation. Either way the children are stable.
Rules
- Branch on size class once, at the top. Two subtrees, compact and regular. Nothing else selects a view.
- Posture is data, never a branch. It feeds split positions, gaps, spacing and alignment into the regular subtree. If you find yourself writing
if posture == .tabletop { SomeOtherView() }, stop.
- Hoist state above the layout. Models, selection, scroll position (
@SceneStorage), playback, text: all above the pane views, so panes can move, resize and re-parent without losing anything.
- Elements that travel keep identity. A floating control that moves from a column to the centre, a panel that goes from overlay to side-by-side: same view, animated frame, or
matchedGeometryEffect. Never one view disappearing while another appears.
- Modes are not postures. A real state machine is fine for modes (edit / preview, learn / play). Modes may change what is shown; postures may not.
What this costs
Be honest in the estimate. Kotlin and Compose to Swift and SwiftUI is a rewrite of the view layer regardless of foldables; models, parsing, networking, engines and native cores port mechanically. So the cost of this section is not a migration of the Android view layer, since that layer is not coming along, but designing the SwiftUI tree correctly before writing it, and deleting the posture state machine rather than porting it. Plan the view layer from scratch around the two size-class subtrees, budget the models as a mechanical port, and treat "port the posture switch" as the one thing on the list that must not happen.
5. Layout patterns per pose
Closed (outer display, compact width)
Same as your phone layout, with two twists: the bar is on the side and vertical space is precious; and the outer camera is always in the corner, aligned with that bar. Standard bars, safe areas and layout margins handle both. Content that scrolls stays inset; immersive, non-scrolling content (a calculator, a player, a game) may span the full width as long as it avoids the Dynamic Island and status bar.
Open, flat (inner display, regular × regular)
Show one more level of hierarchy: list and detail, document and inspector, player and queue. Mail shows list or message when closed and both when open. NavigationSplitView does this; a custom two-pane layout translates to a split ArrangementViewT. Prefer even column counts in grids. The inner display is regular in both dimensions in both orientations, so a landscape-only or portrait-only assumption produces a wrong layout in one of them.
Half-folded, hinge vertical (book pose)
The fold divides the inner display into a left and a right region. Split views keep both columns visible with an even split; alerts move to the trailing side, closer to where they will be when the device closes; grids keep their outer margins and widen the gutter over the hinge. Interactive elements stay off the fold; scrolling text may cross it.
Half-folded, hinge horizontal (tabletop / laptop pose)
The top region suits content viewed at a distance while the bottom suits interactive controls.
A vertical split arrangement puts the primary view on the standing leaf and the secondary on the flat leaf, splitting at the fold; when flat, the same arrangement splits at the middle, so folding only moves the divider. That is the pattern for video and controls, camera preview and shutter, game screen and gamepad, document and keyboard.
The overlay case
A floating panel over content (mini player, reader controls, a picture-in-picture-like preview) is the overlay arrangementT: primary atop secondary when flat, side by side when folded. Use overlayArrangementZIndexT to switch the panel between its compact floating form and its full-leaf form.
Rules for all poses
- Keep navigation containers outside arrangement views; keep arrangement views outside scroll viewsT.
- Sheets, popovers, alerts, context menus and menus reposition themselves around the fold, the camera and the barA. Replace custom floating cards with system presentations wherever you can.
- Animate posture changes as movement of existing elements (section 4). A crossfade between two different screens is exactly the "extreme layout change" the HIG warns about.
6. Bars: from top/bottom to the side
On the outer display and on the inner display in landscape, the status bar, Dynamic Island, navigation bar, toolbar and tab bar share one vertical region on the camera edge: rotate the usual bars by 90° and stack them. It stays on that side in right-to-left languages because it is aligned with the hardware. Only the inner display in portrait keeps horizontal bars. In Split View each app puts its bar on its own outer edge.A
You get this by building against the iOS 27.1 SDK and using bars owned by navigation containers: NavigationStack or NavigationSplitView with .toolbar, and TabView; in UIKit UINavigationController and UITabBarController. Standalone UIToolbar, UINavigationBar, UITabBar and hand-built bottom bars are not moved.T
Translating an Android bar
| Android | iPhone Duo |
| Top app bar: navigation icon, title, actions | Top of the vertical region: Back or Close first, then prominent actions (Done); the title stays with the content |
| Bottom navigation (3 to 5 destinations) | TabView; tabs go into the same vertical region |
| Bottom app bar / FAB | Toolbar items; the FAB becomes a prominent toolbar item with a high visibility priority |
| Overflow ⋮ menu | The system overflow menu (ToolbarOverflowMenu / additionalOverflowItemsA); reserve the ellipsis for it |
| Text-only actions ("Save") | Prefer a symbol with a title; text-only items stay in a horizontal bar and cost vertical space |
| Custom action views | Must fit the bar's fixed width or provide a vertical form; read toolbarVerticalEdgeT; declare AxisBehaviorT |
Housekeeping the HIG asks for:
- Give every item a
Label with both title and symbol; the title is shown in the overflow menu.
- Group items with
ToolbarItemGroup / UIBarButtonItemGroup; no manual spacers (flexible spacers are zero-size verticallyT).
- Items overflow bottom-to-top. Set the visibility priority (
ToolbarItemVisibilityPriority / UIBarButtonItemVisibilityPriorityA) on groups first, then items: frequently used actions (Compose, New) and badged status items overflow last.
- When space is short, decide what survives: navigation-focused apps let the toolbar compress into overflow so the tab bar stays (the default); task-focused apps minimise the tab bar so tool actions stay.
- Keep controls next to the content they act on: list actions above the list pane, not in the side bar.
- Opt out (
toolbarVerticalBehaviorT) only for a single-page, bottom-heavy layout such as a calculator, or a sheet with just a close button.
7. Safe areas, cameras, asymmetry
- Insets are asymmetric: the bar edge and the opposite edge differ, and in Split View the neighbour's bar is on the far side. Read each inset separately; never compute one side from the other.
- Standard bars lay out outside the safe area and avoid the status bar and camera themselves. Keep interactive foreground content inside the safe area; extend backgrounds under bars with
ignoresSafeArea().
- The outer camera region is always present and grows into the Dynamic Island for Live Activities. The inner camera region exists only while the camera is active; the system nudges UI aside when it turns on.
- Use the iOS 26 concentricity APIs (
ConcentricRectangle, UICornerConfiguration) for shapes that hug the display corners instead of hard-coded radii.
- Test both leaves independently, in both hinge orientations, and next to another app.
8. Games and full-screen media
- Make the game playable in every pose. Locking to portrait or landscape is allowed on the outer display; the inner display resizes you anyway, so fill whatever window you get.
- Prefer changing the aspect ratio over letterboxing; if the content has a fixed ratio, put artwork in the padding so the frame still feels full screen.
- Keep text and control sizes consistent across poses; scale the play area, not the HUD.
- Tabletop: play area on the standing leaf, touch controls on the flat leaf, nothing interactive on the fold.
.statusBarHidden and .persistentSystemOverlays(.hidden) still work; the user swipes to reveal.
- The hinge angle (
onHingeChangeT) is available as a live input for effects, a pitch bend or a lamp that dims as the lid closes, but not as a layout signal.
- The Game Controller framework works as on any iPhone;
GCVirtualController gives a system on-screen pad if you want one.
9. Multitasking, scenes, and the other display
- Split View is not opt-inT. Any app can be placed beside another on the inner display and will be resized. If it already resizes on iPad or in iPhone Mirroring, it is most of the way there.
UIRequiresFullScreen no longer prevents resizing3; the app receives discrete size changes when the device opens or closes.
- Multiple scenes (
UIApplicationSupportsMultipleScenes): supported for the first time on an iPhone; new windows can be created only on the inner display, so handle errors from UIWindowScene.ActivationActionT. Apps with process-global state (an emulator core, a single audio engine, a hardware session) should leave this off.
- Scene accessoriesT show extra UI on the outer display while the main UI is on the inner one, the iOS answer to Android's rear-display mode. Availability is controlled by the system; observe it and hide the toggle when unavailable. Camera apps use
CameraCaptureAccessory for a preview or teleprompter facing the subject.
- Cameras:
AVCaptureDevice discovery with .front returns a virtual front camera that switches between the inner and outer camera as the device opens and closesT. Use the individual devices for full capability, and AVCaptureDeviceDirectionCoordinatorT to know which way a camera actually faces relative to your view.
10. Deployment target and fallbacks
One binary has to serve iPhones on your minimum iOS, current iPhones on iOS 27.1, and iPhone Duo. Three facts make this simpler than it looks; one pattern keeps it out of the application code.
Facts
- Checks are on the OS version, never on the device. iPhone Duo ships with iOS 27.1, and every iPhone on 27.1 has the same APIs. There is no "is this a Duo" test and none is needed.
- The new APIs are no-ops without a fold. Reserved regions return nothing, the hinge context is nilT, and an arrangement view with no active division region behaves like the stack it replaces. One code path covers a flat Duo, an iPhone 17 and an iPad.
- Building with the 27.1 SDK does not raise the deployment target. Choose the target from your user base as usual (iOS 26, say); gate the 27.1 APIs at runtime with
#available. #if is compile-time and only matters while part of your CI still builds with an older Xcode. Once the team is on Xcode 27.1, there is no #if at all.
The pattern
Every availability check lives inside a small adapter view or modifier. Application code calls the adapter and never sees #available.
Pseudocode against tech-talk names · will not build until Xcode 27.1 beta ships
// 1. The fold. nil on flat, closed, older iOS and non-folding devices alike.
// Callers cannot tell the difference, which is the point.
struct FoldReader<Content: View>: View {
@ViewBuilder var content: (CGRect?) -> Content
var body: some View {
GeometryReader { proxy in
if #available(iOS 27.1, *) {
content(proxy.reservedRegion(kind: .division).map { proxy.frame(of: $0) }) // [T] name, [I] labels
} else {
content(nil)
}
}
}
}
// 2. Two panes. The system arrangement where it exists, a plain stack everywhere else.
struct AdaptiveSplit<Primary: View, Secondary: View>: View {
@ViewBuilder var primary: () -> Primary
@ViewBuilder var secondary: () -> Secondary
var body: some View {
if #available(iOS 27.1, *) {
ArrangementView(primary: primary, secondary: secondary) // [T]
.arrangementViewStyle(.split) // [T]
} else {
ViewThatFits {
HStack(spacing: 0) { primary(); secondary() }
VStack(spacing: 0) { primary(); secondary() }
}
}
}
}
// 3. Toolbar overflow priority. Applied unconditionally by toolbar code; a no-op before 27.1.
extension ToolbarContent {
@ToolbarContentBuilder
func overflowPriority(high: Bool) -> some ToolbarContent {
if #available(iOS 27.1, *) {
self.visibilityPriority(high ? .high : .low) // [A] type, [I] modifier spelling
} else {
self
}
}
}
The hinge (onHingeChange) and scene accessories take the same treatment: one modifier each, availability inside, an empty implementation below 27.1.
Test matrix
Every row must pass before the app is called ready.
| Configuration | What must hold |
| iPhone on your minimum iOS | Every adapter takes the fallback branch; the layout is what ships today |
| Current iPhone on iOS 27.1 | APIs present but return nothing; result identical to the row above. This proves the no-op path |
| iPhone Duo closed, portrait and landscape | Compact subtree, vertical bar, camera-edge inset |
| iPhone Duo open flat, both orientations | Regular subtree, inactive fold, split at the middle |
| iPhone Duo half-folded, both hinge directions | Active fold; only positions and gaps change, no view is replaced |
| iPhone Duo Split View, left and right slot | Compact subtree, bar on the app's outer edge |
The legacy risk to watch is not the new APIs but the old opt-outs: apps that relied on UIRequiresFullScreen or a portrait lock to avoid ever being resized will be resized on the inner display regardlessT3. Resizing is the prerequisite; the adapters above are the extras.
11. Build, ship, and the refactoring list
| Built with | What you get on iPhone Duo T |
| Older SDK | The app runs in a familiar iPhone-shaped frame on the inner display |
| iOS 27 SDK | Content extends to the left of the status bar on the inner display |
| iOS 27.1 SDK | Edge to edge, vertical bars, arrangements, reserved regions, hinge API |
- Xcode 27.1's Device Hub simulates iPhone Duo with buttons to open, close, rotate and foldT, the counterpart of
adb shell cmd device_state. Screenshot every pose in every mode of your app.
- The "App Resizability" skill in Xcode 27.1 (formerly the UIKit modernisation skill) audits resizing and iPhone Duo readiness for SwiftUI and UIKitT.
- App Store screenshots for the inner display are 2007 × 2853 px; the outer display uses 1398 × 2034 px3.
Refactoring: don't, why, do
One row per Android habit that breaks on iPhone Duo; the "do" column is the acceptance criterion, to be met in every configuration of the test matrix above.