What iOS 27's resizable windows broke in four shipping apps
Resizable iPhone windows silently invalidate every UIDevice idiom check used for layout — the same break, four codebases, one size-class fix.
The break that wasn't in the plan
I found it while working on The Smart Budget's iOS 27 pass, not in Budget itself — in The Smart Billing's SceneDelegate. That file read UIDevice.current.userInterfaceIdiom == .pad once at launch and used the answer to decide whether to build a split-view root or a tab bar.
iOS 27 makes iPhone apps resizable. Once that's true, the idiom check stops answering the question it was being asked. It tells you what hardware you're running on. It does not tell you how wide the window is right now. A user could launch the app narrow — idiom says .phone, tab bar gets built — then widen the window to a size that would normally warrant a sidebar. The root never gets rebuilt. They see a tab bar in a window that has room for a split view.
I audited all four apps in the suite. The pattern came up in each.
What the check was doing
The assumption embedded in every idiom check was that hardware determines layout. iPhone means one column. iPad means two. Close enough to true for long enough that nobody questioned it.
The proxy was already wrong on Mac Catalyst. UIScreen.main on Catalyst reports the physical display, not the app window. The Smart Coach was capping a search results list at 55% of UIScreen.main.bounds.height. On a large monitor with a small app window, that cap could exceed the entire window height. The views weren't obviously broken — just silently sized against the wrong number.
iOS 27 resizable windows surfaces the same class of error on iPhone.
The Billing fix
The Smart Billing had the most concentrated idiom usage: the split-view vs tab-bar decision in SceneDelegate, the call button visibility in two spots, and three UIScreen.main reads for sheet heights and keyboard accessory sizing.
The SceneDelegate change was the most structural. The new code reads horizontalSizeClass and registers for trait changes on the window:
window?.registerForTraitChanges([UITraitHorizontalSizeClass.self]) { [weak self] in
self?.updateRootViewControllerIfNeeded()
}
The ifNeeded guard matters. A resize fires this callback continuously. Without the guard, every pixel of dragging tears down the navigation stack and rebuilds the root, throwing away navigation state. The guard checks whether the size class actually flipped before doing anything.
The call button was a different problem. It was hidden behind userInterfaceIdiom != .pad, which was trying to answer a capability question — can this device place phone calls? — with a layout signal. That was the wrong test before iOS 27. It replaced with canOpenURL(tel:), which is the actual question.
The UIScreen.main sites were more mechanical. Two sheet height caps moved to containerRelativeFrame. The numeric keyboard accessory bar already called sizeToFit(), so seeding it with zero width and letting it measure itself was enough.
Eight end-to-end tests, zero failures on iPhone. Mac Catalyst clean build.
The Coach fix
The Smart Coach had a subtler case in InvoiceFlowLayout. In a Layout.sizeThatFits implementation, a nil proposal.width means the system is asking "how wide do you want to be?" — not "as wide as the screen." The old code answered with the display width and then wrapped rows against that dimension, which the layout had never actually been given.
The fix: nil proposal means unconstrained, which means a single row. Report the width actually used, not the display width.
GlobalSearchView's results cap was easier to see:
// Before
.frame(maxHeight: UIScreen.main.bounds.height * 0.55)
// After — GeometryReader reads the view's actual container
resultsList(maxHeight: proxy.size.height * 0.55)
The file already had a GeometryReader for topPadding, reading a safe-area inset that doesn't change on resize. The results cap needed its own reader, because a resize has to re-evaluate it and a computed property read during body is not guaranteed to re-run.
Eighteen end-to-end tests green. Mac Catalyst clean build.
What you keep
Not every idiom check needed replacing. The Smart Coach has ten. Seven guard camera access alongside the isSourceTypeAvailable check that actually decides the question. Two hide the call button. One picks the split-view root in SceneDelegate, where UISplitViewController already collapses at compact width.
All ten stayed. Camera presence is a hardware fact. A window resize cannot change it. Replacing userInterfaceIdiom with a size class there would be answering the wrong question with whatever number was available.
The rule that came out of this: idiom checks are fine for capability questions. Idiom was wrong for layout questions. The trouble was that layout questions had been phrased as capability questions for so long that the distinction wasn't obvious in the code.
The Catalyst problem was already there
The iOS 27 work exposed the bug, but the bug predates iOS 27. UIScreen.main on Catalyst has always measured the display. Any app that used UIScreen.main.bounds to size something, then ran on Catalyst, was silently measuring the wrong thing. The views were just slightly off in ways that could read as normal.
If you have Catalyst support and haven't audited your UIScreen.main reads, you have the bug today.
What I would do differently
I would have introduced a DeviceCapability type at the start of the first app instead of letting capability questions accumulate as idiom checks scattered through view controllers. By the time I was looking at four codebases, the call-button visibility logic had been copied to two places in Billing and written separately in Coach. Both checked userInterfaceIdiom. Both needed to change.
A canPlaceCalls property backed by canOpenURL(tel:) is three lines. Copying an idiom check is also three lines, but it answers a different question. You only notice when the idiom stops being a reliable proxy.
For the reader
Search your codebase for userInterfaceIdiom. Split the results into layout decisions and capability checks. Layout decisions need to move to size classes and registerForTraitChanges. Capability checks are probably fine — but confirm each one is answering a hardware question, not a layout question in disguise.
Then search for UIScreen.main.bounds. If you have Catalyst support, those reads are already wrong. iOS 27 will surface the same problem on iPhone.
The registerForTraitChanges guard — rebuild only when the size class actually changes — is the part that's easy to miss. Without it, a drag fires the callback on every point, and you can throw away navigation state on every pixel of resize. The system does not debounce it for you.