I have been building a unit converter for iOS since 2016 (convertunits.app). Late 2025 I rewrote it in SwiftUI. Then Apple announced the iPhone Duo, and I had about six weeks to make the app look right on a device I could only run in the Xcode 27.1 simulator.
Two weeks after launch, 824 of my existing users had restored the app on a Duo. This post covers what broke, the code that fixed it, and how I counted those users, since App Store Connect does not list the Duo as a device.
The Duo in one paragraph
Folded, the outer display is a little wider than an iPhone (466 × 678 pt) and compact width. The status bar is not at the top: it sits in a column on the right edge, so the top safe area inset is close to zero and the right one carries everything. Unfolded in landscape, the inner display is 951 × 669 pt, regular × regular, with an 84 pt status column on the right and nothing on the left. The fold crosses the display at its physical middle, x = 475.5 pt, which is not the middle of the safe area.
Every bug below comes from one of those three facts: the top inset is gone, the screen is suddenly wide, and the insets are asymmetric.
What Apple's checklist catches
Apple's resizability guidance targets APIs: UIScreen.main, interface orientation, userInterfaceIdiom, reading insets from the window. My app had almost none of that. No UIScreen, no orientation checks, adaptive grids everywhere. It passed.
Then I opened it on the unfolded simulator, and it looked like an iPhone app stretched across both halves of the fold. The real problems were all in layouts that compile fine and only look wrong on screen.
1. The keypad belongs on the right half
The conversion screen is a list of units above a custom keypad. On a phone that's fine. Unfolded, the keypad became an 867 pt wide strip of giant buttons across both halves of the fold.
The fix: when the content is wider than tall, put the keypad in a side pane. I measure the available space, never the orientation, the idiom or the size class, and I switch the layout with AnyLayout so the views keep their identity. Folding the device mid-input does not reset what you typed.
let layout = isWide
? AnyLayout(HStackLayout(spacing: 0))
: AnyLayout(VStackLayout())
layout {
VStack {
UnitHeader(/* ... */)
ListUnit(/* ... */, keyboardOnSide: isWide)
}
Keyboard(/* ... */, sideBySide: isWide, paneWidth: geometry.rightPaneWidth)
}
.onGeometryChange(for: ContentGeometry.self) { proxy in
ContentGeometry(proxy: proxy)
} action: { newValue in
geometry = newValue
}
The tricky part is the pane width. I wanted the split exactly on the fold, so the keypad fills the right screen. The fold is at the middle of the panel, insets included, not at the middle of the safe area:
init(proxy: GeometryProxy) {
// Duo unfolded, landscape: content 867 × 553, right inset 84, left 0
// → panel middle at 475.5 pt, right pane 392 pt.
let insets = proxy.safeAreaInsets
let fullWidth = proxy.size.width + insets.leading + insets.trailing
let middle = fullWidth / 2 - insets.leading
size = CGSize(width: proxy.size.width.rounded(),
height: proxy.size.height.rounded())
rightPaneWidth = max(0, (proxy.size.width - middle).rounded())
}
Rounding to whole points matters: without it, a window resize triggers a relayout on every frame.
One behaviour change came with it. On a phone, scrolling the list hides the keypad to free space. With the keypad on the side, it hides nothing, so that gesture is now ignored when keyboardOnSide is true.

The keypad now fills the right half of the unfolded Duo, split on the fold.
2. A grid card sitting on the fold
The category screen is a LazyVGrid with .adaptive(minimum: 100, maximum: 200) columns. Unfolded, it produced 7 columns, and the middle one sat right on the hinge.

Before: 7 adaptive columns, the middle one on the fold. After: 3 columns on each side of the fold.
iOS 27.1 reports the fold as a reserved region. I read it with .includeInactive, so the grid does not reshuffle while the device is being opened, and I only care about a vertical fold that crosses the view:
init?(_ proxy: GeometryProxy) {
guard #available(iOS 27.1, *) else { return nil }
let width = proxy.size.width
let regions = proxy.reservedRegions(kind: .division, options: .includeInactive)
guard let region = regions.first(where: { region in
region.frame.height > region.frame.width
&& region.frame.minX > 0 && region.frame.maxX < width
}) else { return nil }
self.width = width.rounded()
minX = region.frame.minX.rounded()
maxX = region.frame.maxX.rounded()
}
When there is a fold, I build an even grid centred on it: the same number of columns on each side, and a central gutter widened by the thickness of the fold.
let middle = (fold.minX + fold.maxX) / 2
let half = min(middle, fold.width - middle) // the shorter side sets the width
leadingInset = middle - half
trailingInset = fold.width - middle - half
let gutter = spacing + (fold.maxX - fold.minX)
let usable = half - gutter / 2
let perSide = max(1, Int((usable + spacing) / (minimum + spacing)))
var columns = Array(repeating: GridItem(.flexible(), spacing: spacing), count: perSide * 2)
columns[perSide - 1] = GridItem(.flexible(), spacing: gutter)
Everywhere else (iPhone, iPad, Duo folded, Duo unfolded in portrait where the fold is horizontal and crosses scrolling content) the initializer returns nil and the original adaptive grid is used.
3. "Skip" glued to the top edge
Onboarding and paywall screens have no navigation bar. On a phone, the top safe area inset (about 60 pt) keeps their first line away from the edge. On the Duo the top inset is 0, so "Skip", titles and close buttons touched the edge of the display.
A fixed .padding(.top, 24) would push everything down on iPhone too. Instead, this modifier only adds what the safe area does not already give:
func minimumTopMargin(_ length: CGFloat) -> some View {
modifier(MinimumSafeAreaMargin(edge: .top, length: length))
}
private struct MinimumSafeAreaMargin: ViewModifier {
let edge: VerticalEdge
let length: CGFloat
@State private var inset: CGFloat?
func body(content: Content) -> some View {
content
.padding(edge == .top ? .top : .bottom, extra)
.onGeometryChange(for: CGFloat.self) { proxy in
let insets = proxy.safeAreaInsets
return (edge == .top ? insets.top : insets.bottom).rounded()
} action: { inset = $0 }
}
private var extra: CGFloat {
guard let inset else { return 0 }
return max(0, length - inset)
}
}
On an iPhone in portrait the padding is zero and nothing moves. iPhones in landscape, where the top inset is also 0, get the fix for free. Measuring the padded view avoids a measure → pad → measure loop, because its top edge does not move when the padding changes.
4. Lines 800 pt long
On a phone, the screen width is what limits a row. Unfolded, nothing did: labels sat 800 pt away from their values, and buttons became huge. I capped content at a readable column of 640 pt, centred, with backgrounds still edge to edge:
enum ReadableColumn {
// Close to Apple's readable width, below the 867 pt of the unfolded Duo.
static let maxWidth: CGFloat = 640
}
func readableColumn() -> some View {
frame(maxWidth: ReadableColumn.maxWidth)
.frame(maxWidth: .infinity)
}
For List and Form, wrapping the whole list in a frame breaks scrolling at the edges. On iOS 17+ I use contentMargins(_:_:for: .scrollContent) instead, and add each side's safe area inset to its margin. Otherwise the column is centred on the screen, not on the safe area, and the Duo's status column is on one side only.
5. Settings that push a full-screen detail
The settings for categories and favourites were a list that pushes a detail screen. On a 951 pt wide display, that means a full-width list, then a full-width detail. In regular width they now use a NavigationSplitView, and in compact width they keep the list plus push. On the Duo, the split lands on the fold.

Categories on the left, units on the right.
6. A modal window on the wrong scene
This is the one bug Apple's checklist would have caught. The app shows toasts and modals in a transparent overlay window, attached like this:
// Before
var windowScene: UIWindowScene? {
(UIApplication.shared.connectedScenes.first as? UIWindowScene)!.windows.first?.windowScene
}
"The first connected scene" is not guaranteed to be the one showing the app, and the force unwrap crashes when it is not a window scene. Now a tiny invisible UIView tells the app which scene it actually lives in:
struct WindowSceneReader: UIViewRepresentable {
let onScene: (UIWindowScene) -> Void
func makeUIView(context: Context) -> SceneReportingView {
let view = SceneReportingView()
view.isUserInteractionEnabled = false
view.onScene = onScene
return view
}
func updateUIView(_ uiView: SceneReportingView, context: Context) {
uiView.onScene = onScene
}
final class SceneReportingView: UIView {
var onScene: ((UIWindowScene) -> Void)?
override func didMoveToWindow() {
super.didMoveToWindow()
if let windowScene = window?.windowScene { onScene?(windowScene) }
}
}
}
It sits in a .background of the root view, and the overlay window follows the scene each time the view moves to a new window.
Did it matter? Counting Duo users
App Store Connect's analytics reports have a Device column, but its values are iPhone, iPad, Desktop, Apple Vision and iPod. The Duo is counted as an iPhone.
The trick is the OS version. At launch the Duo shipped on iOS 27.1, and regular iPhones went from 27.0 to 27.2. My Firebase data confirmed it: of 27 users on iOS 27.1, 26 were on an iPhone Duo. So in the App Downloads report, iPhone + iOS 27.1 is a good proxy for the Duo. It is a floor, since Duo owners who updated to 27.2 blend in with everyone else.
From October 22 to November 1:
| Download type | Count |
|---|---|
| Restore (app moved from an old iPhone) | 824 |
| First-time download | 16 |
| Redownload | 12 |
446 of those came on October 23, the first day the Duo was in stores.
The takeaway surprised me. The Duo brought almost no new users, but it brought hundreds of existing ones, on day one, onto a screen I had never touched with a finger. All of them got the adapted version. If I had waited for complaints, their first impression of the app on their new phone would have been a stretched layout.
About 60 of them have opened the app on the Duo so far. A converter is something you open when you need it, so that number should keep growing.
What I would tell you
- Measure the space you have, not the device. Wide or tall, inset or no inset. Size classes on the unfolded Duo are regular × regular, which looks like an iPad, but the display is only 669 pt tall.
- The fold is in the middle of the panel, not in the middle of the safe area. Compute it from the full width.
- Round every measured value before it reaches
@State. - Check every fix on a regular iPhone. "Unchanged on iPhone" should be a promise you verify with a screenshot.
- Do it before launch. Your existing users will restore your app on day one.
Written with AI assistance. The code, measurements and numbers come from my app and my App Store Connect account.
Top comments (0)