Apple: "Your app supports Portrait and Landscape, right?"
Also Apple: "Cute. Now make it work at literally any window size."
_
Why every iOS developer needs to rethink layout architecture in the era of adaptive windows.
Introduction
You've spent months perfecting your iPhone application.
It looks fantastic on the iPhone SE, the iPhone 16, the Pro Max, and iPad. You ship. Everything works.
Then Apple pushes the interaction model further freeform resizing, Stage Manager, external displays, floating windows and users start dragging your app's window to whatever size fits their workflow.
Suddenly, it becomes like in image below:
Nothing technically breaks. No crash, no constraint warning in the console. But everything feels broken because the layout was designed for screens, not windows, and that distinction is about to matter a lot more than it used to.
For over fifteen years, iOS developers have thought in terms of devices: iPhone, iPad, portrait, landscape. The next era of Apple platforms demands a different mental model one built around available space, not device identity.
This article walks through that mindset shift and builds a production-ready, adaptive product detail page (PDP) that gracefully reflows as its window changes size including a custom SwiftUI Layout implementation you can drop straight into a real app.
Why This Matters Now
For most of iOS history, your app's canvas was effectively fixed at runtime. Rotation gave you two known layouts, and that was the extent of your "adaptive" surface area.
That assumption is breaking down. Modern Apple platforms increasingly embrace:
- Split View and Slide Over on iPad
- Stage Manager, with genuinely freeform window sizing
- External and wireless displays
- Multiwindow scenes on iPadOS and visionOS
- Continuously variable widths, not two or three fixed states
Users don't think about which device they're on. They think about whether your app behaves naturally in whatever space they've given it. Supporting "smaller widths" as an edge case isn't enough anymore the goal is continuous adaptation across the entire width spectrum, not just graceful degradation at the extremes.
The Biggest Mistake Most Apps Make
Here's the pattern nearly every app reaches for first:
if horizontalSizeClass == .compact {
ProductMobileView()
} else {
ProductTabletView()
}
It works until it doesn't. horizontalSizeClass collapses an enormous range of real widths into exactly two buckets. But picture a window resizing continuously:
390 → 410 → 455 → 512 → 575 → 612 → 640 → 700 → 790 → 850
Your UI doesn't have two states. It has hundreds. Binary size-class branching leaves a massive, awkward gap between "phone" and "tablet" where the layout is neither optimized nor intentional it's just whichever of the two views got picked, stretched or squeezed to fit.
The fix isn't a third if branch. It's abandoning device-based branching as the primary layout mechanism altogether.
The Example We'll Build
We'll design a realistic e-commerce Product Detail Page (PDP) the kind you'd find in any modern retail app containing:
- Gallery
- Product name, brand, rating
- Price
- Colour picker / size picker
- Wishlist action
- Delivery information
- Description
- Reviews
- Recommended products
This isn't a toy example. It's representative of the layout complexity retail apps deal with daily, where imagery, metadata, actions, and recommendation carousels all have to coexist and reflow together.
Starting Point: The Naïve Layout
Most implementations look like this:
ScrollView {
VStack(spacing: 24) {
ProductGallery()
ProductInformation()
SizeSelector()
ColorSelector()
AddToBagButton()
Description()
Reviews()
Recommendations()
}
}
This is fine on a phone. But as available width grows, it becomes wasteful long single-column scrolling, oceans of whitespace on either side, and a gallery that never gets to breathe next to the content it's describing.
Thinking in Layout Zones, Not Devices
Instead of asking "Am I on an iPhone?", ask "How much horizontal space do I actually have right now?"
This is the fundamental shift. Rather than branching on device identity, define logical width ranges that describe how content should be organized:
| Width Range | Experience |
|---|---|
| Narrow (< ~600pt) | Single-column layout optimized for constrained space |
| Medium (~600–900pt) | Two-column layout gallery and details side by side |
| Wide (> ~900pt) | Multi-pane layout introducing recommendations or supplemental panes |
These are guidelines, not hard device categories the interface adapts continuously as the window changes, and the breakpoints themselves should be tuned against real content, not memorized from a spec sheet.
Visual Evolution
Notice what doesn't change: the content. Only its organization shifts. That's the tell of a well-architected adaptive layout the data and view models stay identical; only composition changes.
Architecture Before Code
A common anti-pattern is embedding layout decisions directly inside views:
if width > 700 {
HStack { /* ... */ }
} else {
VStack { /* ... */ }
}
This works initially but rots fast every screen ends up with its own bespoke breakpoint logic, and nobody remembers why 700 was chosen versus 680.
A more scalable approach separates layout decision-making from presentation:
enum LayoutStyle {
case narrow
case medium
case wide
init(width: CGFloat) {
switch width {
case ..<600: self = .narrow
case 600..<900: self = .medium
default: self = .wide
}
}
}
A single source of truth determines the current LayoutStyle from available width; the view hierarchy simply reacts to that style. This keeps your UI declarative, testable, and far easier to evolve you can unit test LayoutStyle(width:) in isolation without spinning up a single view.
Reading Width the Right Way: GeometryReader vs. Container Values
GeometryReader is the classic tool here, but it's a blunt instrument it greedily fills its parent and ignores the natural sizing of its children, which can quietly break layouts that worked fine before you added it.
struct AdaptiveProductPage: View {
var body: some View {
GeometryReader { proxy in
let style = LayoutStyle(width: proxy.size.width)
ProductPageContent(style: style)
}
}
}
For most PDP-style screens this is perfectly adequate, since the page is already the root of its own scroll context. But if you're nesting adaptive components inside other adaptive components, prefer reading size via .onGeometryChange(for:of:action:) (iOS 18+) or a PreferenceKey-based width reporter, so children don't accidentally force their parents to expand:
struct WidthReader<Content: View>: View {
@State private var width: CGFloat = 0
let content: (CGFloat) -> Content
var body: some View {
content(width)
.background {
GeometryReader { proxy in
Color.clear
.preference(key: WidthKey.self, value: proxy.size.width)
}
}
.onPreferenceChange(WidthKey.self) { width = $0 }
}
}
private struct WidthKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
Composing the Layout
With LayoutStyle established, the top-level view becomes a simple switch over composition, not appearance:
struct ProductPageContent: View {
let style: LayoutStyle
var body: some View {
ScrollView {
switch style {
case .narrow:
narrowLayout
case .medium:
mediumLayout
case .wide:
wideLayout
}
}
}
private var narrowLayout: some View {
VStack(spacing: 24) {
ProductGallery()
ProductInformation()
SizeSelector()
AddToBagButton()
Description()
Reviews()
Recommendations()
}
}
private var mediumLayout: some View {
VStack(spacing: 24) {
HStack(alignment: .top, spacing: 20) {
ProductGallery()
VStack(alignment: .leading, spacing: 16) {
ProductInformation()
SizeSelector()
AddToBagButton()
}
}
Description()
Reviews()
}
}
private var wideLayout: some View {
VStack(spacing: 24) {
HStack(alignment: .top, spacing: 24) {
ProductGallery()
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 16) {
ProductInformation()
SizeSelector()
AddToBagButton()
}
.frame(maxWidth: 340)
Recommendations()
.frame(maxWidth: 280)
}
Reviews()
}
}
}
Each component ProductGallery, ProductInformation, SizeSelector, Recommendations knows nothing about where it will be placed. It only renders its own content. The composition layer decides arrangement. This separation of concerns means you can reorder, add, or remove regions without touching business logic or view models.
Bonus: A Custom Layout for True Fluidity
Discrete breakpoints (narrow / medium / wide) are a huge improvement over size-class branching, but they're still steps, not a continuum. For cases where you want genuinely fluid reflow say, a tag list or a filter chip row that should wrap based on exact available width SwiftUI's Layout protocol (iOS 16+) is the right tool:
struct FlowLayout: Layout {
var spacing: CGFloat = 8
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
let width = proposal.width ?? .infinity
var rowWidth: CGFloat = 0
var totalHeight: CGFloat = 0
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if rowWidth + size.width > width {
totalHeight += rowHeight + spacing
rowWidth = 0
rowHeight = 0
}
rowWidth += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
totalHeight += rowHeight
return CGSize(width: width, height: totalHeight)
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
var x = bounds.minX
var y = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if x + size.width > bounds.maxX {
x = bounds.minX
y += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: x, y: y), proposal: .unspecified)
x += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}
Drop this in for size pickers, filter chips, or delivery-option badges, and they'll wrap naturally at any width no breakpoint table required. Layout is also cheap: unlike GeometryReader, it participates properly in SwiftUI's normal sizing negotiation instead of overriding it.
Don't Forget ViewThatFits
For simpler cases swapping a compact control for a full one when space runs out ViewThatFits (iOS 16+) is often all you need, and it's far less code than manual width math:
ViewThatFits(in: .horizontal) {
HStack { Text("Add to Bag"); Image(systemName: "bag") }
Image(systemName: "bag")
}
SwiftUI tries each option in order and renders the first one that fits genuinely elegant for toolbar-style controls and buttons that need to shed content before they shed usefulness.
Preparing an Existing App
If your app already ships to production, a full rewrite isn't necessary. A gradual migration tends to work best:
- Audit every place layout branches on device checks or orientation.
-
Replace those checks with width-driven
LayoutStyledecisions. - Extract reusable UI sections (gallery, info block, recommendations) into standalone components with no layout opinions of their own.
-
Introduce an adaptive composition layer that arranges those components differently per
LayoutStyle. - Test by resizing continuously not just by toggling portrait/landscape in the simulator.
This incremental path minimizes risk while steadily raising the ceiling on how well your app handles space it wasn't originally designed for.
Testing Adaptive Layouts Properly
A layout that "works" only at the exact widths you happened to test isn't adaptive it's coincidentally correct. A few practices that catch real issues early:
-
Xcode Previews at multiple fixed widths. Use
.previewLayout(.fixed(width:height:))across a spread of values (e.g. 320, 428, 600, 834, 1194) rather than relying on device presets alone. - Resize the simulator window live rather than only rotating it this is the one Split View and Stage Manager actually exercise.
- Snapshot tests at breakpoint boundaries, not just breakpoint centers bugs cluster at the transition points (599pt vs. 601pt), not in the middle of a range.
- Dynamic Type + resizing together. A layout that survives resizing but breaks under XXL Dynamic Type isn't done test both axes simultaneously, since real users combine them.
Accessibility Benefits
Designing for resizing pays off beyond windowing scenarios. Users who increase Dynamic Type, enable Display Zoom, or work in Split View are already changing the effective space available to your app from your layout code's perspective, that's indistinguishable from a user resizing a window.
An interface built to gracefully reorganize itself across widths is, almost as a side effect, more resilient to larger text, alternative input methods, and whatever form factor Apple ships next. Adaptive design and accessible design reinforce each other; you rarely get one without the other.
Conclusion
For years, iOS development revolved around screens. We memorized device dimensions, size classes, and orientation pairs.
The next evolution asks for a different habit of mind not devices, but space; not portrait versus landscape, but fluid layouts that reorganize themselves intelligently as the width beneath them changes.
Build from adaptable, layout-agnostic components rather than fixed screen assumptions, and your app becomes resilient not just to today's device lineup, but to whatever Apple introduces next.
The question is no longer "Does my app support iPhone and iPad?"
It's "Can my interface make the best use of whatever space it's given?"
That shift is what separates an app that merely runs everywhere from one that actually feels at home everywhere.





Top comments (0)