I make PawLife, a small pet health app (feeding plans in grams, a health diary, a food safety lookup). It is one SwiftUI codebase for iPhone, iPad, Mac Catalyst, Apple Watch and visionOS. When Xcode 27.2 shipped simulators for the iPhone Duo, I expected "it is just a wider iPhone". It is not. Here is what actually broke, in the order I found it, and what the fixes looked like.
Two displays, one app, three postures
The Duo has an outer display and an inner one across the fold. Your app runs on one of them at a time, and the window changes size when the user folds or unfolds. The simulator reports it as a window resize, not a new scene. Measured on the simulator:
- Folded: a 466×678 pt window, of which my app got 382 pt of width. The remaining 84 pt on the trailing edge is a reserved column (status area and camera) exposed through the safe area.
- Open: a landscape 951×669 pt window, 867 pt for the app, with the same 84 pt trailing column.
So the app goes from a narrow portrait phone to a wide landscape slab and back, live, without a relaunch. Everything that cached its width at first layout was wrong after the first fold.
1. containerRelativeFrame across the fold
My page views used containerRelativeFrame(.horizontal) for carousels and card widths. On the inner display the container width is the whole slab, so a "one page" card stretched across the hinge, and after a fold it kept the old width for a frame and clipped. On the Duo the container is simply not a stable reference.
Fix: measure the window once, at the root, and pass the content width down through the environment. Pages clamp to that value instead of asking the container. I called it pawContainerWidth(), and it reads a window-width environment value set by the main tab view. Boring, but it is the only thing that behaved identically on iPhone, iPad, Mac and both Duo postures.
2. The reserved column is a safe-area inset, treat it as one
The 84 pt trailing column arrives as safeAreaInsets.trailing. Views that ignored the safe area (ignoresSafeArea() on backgrounds, full-bleed sheets, the builder-rendered paywall from Adapty) drew under the camera. In the folded posture a close button landed exactly under it.
Fix: one modifier that pushes the whole subtree clear of the occluded region and fills the gap with the screen's own background. I apply it to the three things that insist on being edge-to-edge: the onboarding flow, the paywall, and full-screen media. Everything else respects the safe area by default and was fine.
Bonus: once the trailing inset is reliably reported, it becomes free real estate. When the trailing inset is 72 pt or more, PawLife moves its tab bar into that column as a vertical rail. On a normal iPhone the inset is zero and the rail never appears.
3. Layout by width, not by idiom
Checking userInterfaceIdiom == .pad to decide on a two-column layout fails here: the Duo is a .phone that is wider than an iPad mini in portrait. I replaced idiom checks with width thresholds. Home and Pets go to two columns when the content width crosses the hinge span (I keep that number in one place, pawHingeSpan). Lists that read better as one column (nutrition, health, chat) stay one column even on the open display, and the crease then runs through whitespace rather than through a row.
4. Sheets and covers after a fold
A sheet presented while folded stayed phone-sized after unfolding, floating in the middle of the slab. A fullScreenCover did the opposite and did not shrink when folding. I already route every presentation through two wrappers (for an unrelated Catalyst environment bug), so the fix was local: the wrappers re-read the window size on change and re-apply detents. If you present sheets directly all over the codebase, this is the one that will hurt.
5. Testing without losing an afternoon
Things I would have liked to know on day one:
- In Xcode 27 the simulators live in DeviceHub, not Simulator.app. Posture is switched with the three buttons at the bottom right of the device window (folded, book, open).
-
simctl io <udid> screenshot --display=1captures the outer display,--display=3the inner one. The inner one is black while folded, which looks like a rendering bug and is not. - Do not launch with a
-selectedTabstyle argument and then test tab navigation. The argument pins the tab and the rail looks dead. That one cost me an hour. - If you write a UI test, give it a fold step. The bugs above only show on the transition, never on a fresh launch in either posture.
What I would do differently
Starting from scratch, I would never let a view ask "how wide is my container" and instead give every screen a content width from the root. Everything else on the Duo (the reserved column, the posture change, the second display) is a consequence of that one decision being right or wrong.
PawLife runs on the Duo today in both postures. If you have one and something looks off, the food safety table at https://pawlife.pet/dogs/food-safety is the heaviest page in the app and a good place to try to break it.
Top comments (0)