Greetings, traveler!
There are several ways to detect whether a SwiftUI interface is currently wider than it is tall.
You can read UIDevice.current.orientation. You can compare the width and height reported by GeometryReader. You can also use size classes as a signal for different interface variants.
All of these approaches can be useful. The problem appears when orientation becomes the main input for layout.
In many cases, the interface does not actually care whether the device is in portrait or landscape. It cares whether there is enough room for a specific arrangement.
That leads to a better question:
What layout fits in the space available to this view?
Detecting Orientation
The most obvious option is UIDevice.current.orientation:
let orientation = UIDevice.current.orientationYou can then check:
orientation.isLandscapeThis value describes the physical orientation of the device. It can also report values such as:
.faceUp
.faceDown
.unknownThat makes it a poor source of truth for layout.
For UI decisions, the shape of the current container is usually more useful than the physical orientation of the hardware.
A better check is:
GeometryReader { proxy in
let isLandscape = proxy.size.width > proxy.size.height
if isLandscape {
LandscapeView()
} else {
PortraitView()
}
}This does not tell you that the device is physically in landscape. It tells you that the current container is wider than it is tall.
That distinction matters.
If this is exactly the information you need, the approach is valid. But in most adaptive layouts, even this check is unnecessary.
Orientation Is Often the Wrong Question
Consider this:
if isLandscape {
HStack {
DetailsView()
ActionsView()
}
} else {
VStack {
DetailsView()
ActionsView()
}
}The actual requirement is probably not:
Use
HStackin landscape.
It is:
Use
HStackwhen there is enough horizontal space.
Those are different conditions.
The same applies to device checks:
if UIDevice.current.userInterfaceIdiom == .pad {
SidebarLayout()
} else {
PhoneLayout()
}This assumes that iPad means wide and iPhone means narrow.
A view should usually adapt to the space its parent gives it, not to the hardware model running the app.
That makes the layout more reusable and avoids assumptions about screen size, multitasking, windows, sheets, or nested containers.
Start With SwiftUI’s Regular Layout System
Many adaptive layouts do not need geometry at all.
SwiftUI already negotiates sizes between parents and children, so ordinary stacks, grids, flexible frames, alignment, and layout priorities often provide enough flexibility.
For example:
HStack {
Text("Title")
Spacer()
Button("Edit") {
// ...
}
}Or:
Text(content)
.frame(maxWidth: .infinity, alignment: .leading)A grid can adapt automatically:
LazyVGrid(
columns: [
GridItem(.adaptive(minimum: 160))
]
) {
ForEach(items) { item in
CardView(item: item)
}
}The number of columns follows the available width, so no orientation or device check is needed.
When the layout system can express the requirement directly, that is usually the most robust solution.
Use ViewThatFits When You Have Alternative Layouts
ViewThatFits is useful when the same content has more than one valid arrangement.
For example:
ViewThatFits(in: .horizontal) {
HStack(spacing: 12) {
controls
}
VStack(alignment: .leading, spacing: 12) {
controls
}
}SwiftUI tries the first view and uses it if it fits in the proposed space. Otherwise, it tries the next one.
The requirement here is clear:
Prefer the horizontal layout when it fits.
There is no need to invent a breakpoint such as:
if proxy.size.width > 600 {
HStack {
controls
}
} else {
VStack {
controls
}
}A hardcoded threshold may still be appropriate in some designs, but ViewThatFits is often better when the decision depends on the actual content.
This also makes the layout more resilient to longer text, localization, and larger text sizes.
Use Size Classes for Broad Layout Modes
Size classes are useful for larger structural changes:
@Environment(\.horizontalSizeClass)
private var horizontalSizeClassThen:
if horizontalSizeClass == .regular {
ExpandedLayout()
} else {
CompactLayout()
}This works well for decisions such as:
- single-column versus multi-column presentation,
- compact versus expanded controls,
- sidebar-style navigation versus a simpler stacked layout.
Size classes do not describe orientation or device type.
A .regular horizontal size class does not mean “iPad”, and .compact does not mean “iPhone”. They are coarse descriptions of the space available to the interface.
Use them for semantic layout modes, not for exact measurement.
If you only need to know whether a particular set of controls fits in one row, ViewThatFits is usually a better fit.
Use AnyLayout When the Arrangement Changes
Sometimes the views stay the same but their container changes.
For example:
let layout = horizontalSizeClass == .regular
? AnyLayout(HStackLayout(spacing: 16))
: AnyLayout(VStackLayout(spacing: 16))
layout {
ProfileView()
StatisticsView()
ActionsView()
}AnyLayout lets you switch between layout containers while preserving the identity of the child views.
It does not decide when the switch should happen. That decision still needs a meaningful input such as:
- a size class,
- Dynamic Type,
- application state,
- another layout-specific condition.
The advantage is that the layout changes without duplicating the child hierarchy.
Use the Layout Protocol for Custom Placement Rules
When a component has real placement logic, the Layout protocol is usually a better abstraction than measuring a parent and manually updating state.
A custom layout can inspect the space proposed by its parent, measure its subviews, and place them accordingly.
This is useful for components such as:
- wrapping tags,
- adaptive toolbars,
- flow layouts,
- custom card arrangements,
- layouts with non-standard alignment rules.
For example:
struct AdaptiveLayout: Layout {
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// Measure subviews and calculate the required size.
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// Place subviews inside the available bounds.
}
}The important difference is architectural: the layout participates directly in SwiftUI’s layout negotiation instead of measuring the result afterward and feeding it back through state.
For a simple UI, this would be unnecessary. For a reusable component with custom placement behavior, it is often the right tool.
Use GeometryReader When Exact Geometry Matters
GeometryReader is useful when numeric dimensions are part of the design.
Typical examples include:
- charts,
- drawing,
- proportional positioning,
- geometry-based animations,
- custom visual effects,
- local measurements.
For example:
GeometryReader { proxy in
Circle()
.frame(
width: proxy.size.width * 0.5,
height: proxy.size.width * 0.5
)
}Here the exact width is relevant, so measuring it makes sense.
The important rule is to measure as locally as possible.
Avoid measuring the entire screen or root view just so that nested components can calculate their own layout from a global number.
A component should usually respond to its own container.
Avoid Turning Geometry Into Application State
A common implementation looks like this:
@State private var isLandscape = falseThen geometry updates that state:
.onChange(of: proxy.size) { _, newSize in
isLandscape = newSize.width > newSize.height
}And the layout depends on isLandscape.
This works, but isLandscape is not really application state. It is a value derived from the current geometry.
That creates an extra cycle:
layout
→ measurement
→ state update
→ body update
→ layoutIf the value is only needed locally, derive it locally:
GeometryReader { proxy in
let isWide = proxy.size.width > proxy.size.height
content(isWide)
}Better yet, avoid the measurement if ViewThatFits, an adaptive grid, or a custom Layout can express the same requirement directly.
Use Breakpoints for the Interface, Not for Hardware
A fixed breakpoint can be perfectly reasonable:
if width >= 700 {
ExpandedView()
} else {
CompactView()
}The number should represent a design decision.
It should not exist because one particular device happens to have that width.
Avoid checks like:
if width == 393 {
// specific iPhone layout
}or:
if width > 430 {
// probably iPad
}Those are device assumptions disguised as layout logic.
A good breakpoint describes the interface.
A bad breakpoint describes hardware.
Adaptive Layout Also Depends on Accessibility
Width is not the only thing that can change how much space a layout needs.
Dynamic Type, localization, and content length can all affect whether an arrangement still works.
For example:
@Environment(\.dynamicTypeSize)
private var dynamicTypeSizeYou may decide to switch from a horizontal to a vertical arrangement for accessibility text sizes:
let layout = dynamicTypeSize.isAccessibilitySize
? AnyLayout(VStackLayout(spacing: 12))
: AnyLayout(HStackLayout(spacing: 12))
layout {
Label("Notifications", systemImage: "bell")
Toggle("", isOn: $enabled)
}The container did not change orientation.
The content requirements changed.
This is another reason to base layout decisions on actual constraints instead of treating orientation as the main signal.
Prefer System Containers for Navigation
Adaptive layout also applies to navigation and structural UI.
Use system containers where possible:
NavigationStack {
ContentView()
}or:
NavigationSplitView {
SidebarView()
} detail: {
DetailView()
}The same applies to TabView and ToolbarItem.
These APIs carry semantic information that the system can use when adapting the interface.
A custom view pinned to a specific edge only knows the position you gave it.
Custom UI still makes sense when the custom behavior is part of the product. For standard navigation and structural elements, system containers are usually a better foundation.
Do Not Ask the Screen for the Size of a View
One of the most common layout mistakes is still:
let width = UIScreen.main.bounds.widthfollowed by calculations such as:
let cardWidth = (width - 48) / 2The screen width and the available width are not necessarily the same thing.
A view can live inside:
- a split view,
- a window,
- a sheet,
- a sidebar,
- another custom container.
If a component needs its available width, measure or infer the space available to that component.
Do not use the physical screen as a proxy.
Choosing the Right Tool
A useful rule of thumb is:
| Requirement | Tool |
|---|---|
| Content should naturally stretch or reflow | stacks, grids, flexible frames |
| Choose the first layout that fits | ViewThatFits |
| Switch between broad compact and expanded modes | size classes |
| Change stack type while preserving child identity | AnyLayout |
| Implement custom placement rules | Layout |
| Read exact local dimensions | GeometryReader |
| Know whether a container is wider than it is tall | compare width and height |
The last case is valid, but it is much narrower than “build a responsive UI.”
When Comparing Width and Height Is Still Correct
There are cases where the aspect ratio of the current container really is the information you need.
Then this is perfectly reasonable:
GeometryReader { proxy in
let isLandscapeContainer =
proxy.size.width > proxy.size.height
CustomCanvas(
isLandscape: isLandscapeContainer
)
}A name such as isLandscapeContainer or isWide is more precise than deviceOrientation, because it describes what is actually being measured.
This should remain a local layout decision rather than global device state.
Conclusion
There is nothing wrong with checking whether a SwiftUI container is wider than it is tall when its aspect ratio is relevant.
The problem is using that value as the foundation of adaptive layout.
In most cases, the better question is not:
Is this landscape?
It is:
What fits in the space I’ve been given?
