DEV Community

Cover image for What broke when my SwiftUI app met the iPhone Duo (and what Apple's checklist missed)
Olivier BERNAL
Olivier BERNAL

Posted on Edited on AI-assisted

What broke when my SwiftUI app met the iPhone Duo (and what Apple's checklist missed)

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
}
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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 conversion screen on the unfolded iPhone Duo: the list of units on the left half, the keypad on the right half
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.

Category grid on the unfolded iPhone Duo, before and after: 7 columns with one on the fold, then 3 columns on each side
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()
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

Settings on the unfolded iPhone Duo with a NavigationSplitView: categories on the left, the units of Mass on the right
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
}
Enter fullscreen mode Exit fullscreen mode

"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) }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)