Building Adaptive iOS Layouts with Protocol-Oriented Design
- lioneldude
- 7 days ago
- 3 min read

When building universal iOS apps, you'll often need to adapt your UI for different devices—especially between iPhone and iPad. While SwiftUI provides @Environment(\.horizontalSizeClass) and @Environment(\.verticalSizeClass), checking these repeatedly can become verbose and repetitive.
Here's an elegant, protocol-oriented approach I've been using to make adaptive layouts cleaner and more maintainable.
The Problem
Typical adaptive layout code looks like this:
struct ContentView: View {
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass
var body: some View {
if horizontalSizeClass == .regular && verticalSizeClass == .regular {
// iPad layout
} else {
// iPhone layout
}
}
}This works, but:
The size class logic is repeated across multiple views
The boolean expression horizontalSizeClass == .regular && verticalSizeClass == .regular isn't semantic
Every view needs to declare the same @Environment properties
The Solution: PlatformView Protocol
Instead, we can create a protocol that encapsulates this logic:
import SwiftUI
@MainActor
protocol PlatformView: View {
var verticalSizeClass: UserInterfaceSizeClass? { get }
var horizontalSizeClass: UserInterfaceSizeClass? { get }
var isRegularSize: Bool { get }
var isCompactSize: Bool { get }
}
extension PlatformView {
var isRegularSize: Bool {
horizontalSizeClass == .regular && verticalSizeClass == .regular
}
var isCompactSize: Bool {
horizontalSizeClass == .compact || verticalSizeClass == .compact
}
}What's happening here?
The protocol requires conforming types to provide verticalSizeClass and horizontalSizeClass
The protocol extension provides default implementations for isRegularSize and isCompactSize
These computed properties give us semantic, readable checks for device types
Using PlatformView in Your Views
Now your views become much cleaner:
struct ContentView: PlatformView {
// Add size classes to conform to PlatformView
@Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.horizontalSizeClass) var horizontalSizeClass
var body: some View {
VStack {
// Since the view now conforms to PlatformView,
// we can use properties in the protocol
if isRegularSize {
// Show regular sized content designed for iPad
Text("This is the iPad view")
} else {
// Show compact sized content
Text("This is the iPhone view")
}
if isCompactSize {
Text("This is specifically for iPhone")
}
}
.padding()
}
}Why This Approach Works
1. Semantic Readability
isRegularSize is more readable than horizontalSizeClass == .regular && verticalSizeClass == .regular
Intent is clear at a glance
2. Reusability
Define the logic once, use it everywhere
Any view can adopt PlatformView and get these conveniences
3. Maintainability
If you need to change how you determine "regular" vs "compact", you update it in one place
Consistent behavior across your entire app
4. Type Safety
The protocol ensures you have the required properties
The compiler helps you maintain correctness
Real-World Example
Here's a more practical example showing a dashboard that adapts its layout:
struct DashboardView: PlatformView {
@Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.horizontalSizeClass) var horizontalSizeClass
var body: some View {
if isRegularSize {
// iPad: side-by-side layout
HStack(spacing: 20) {
StatisticsPanel()
ChartsPanel()
ActivityPanel()
}
.padding(40)
} else {
// iPhone: stacked layout
ScrollView {
VStack(spacing: 16) {
StatisticsPanel()
ChartsPanel()
ActivityPanel()
}
.padding(20)
}
}
}
}Advanced: Conditional Modifiers
You can even use this for conditional view modifiers:
struct SettingsView: PlatformView {
@Environment(\.verticalSizeClass) var verticalSizeClass
@Environment(\.horizontalSizeClass) var horizontalSizeClass
var body: some View {
Form {
// Settings content
}
.navigationBarTitleDisplayMode(isCompactSize ? .inline : .large)
.frame(maxWidth: isRegularSize ? 600 : .infinity)
}
}Note on @MainActor
You might notice the @MainActor annotation on the protocol. While technically redundant (since View is already @MainActor-isolated), it serves as explicit documentation of the threading requirements and can be helpful for clarity.
Wrapping Up
Protocol-oriented design is one of Swift's strengths, and this pattern shows how a small abstraction can significantly improve code quality. Instead of scattering size class logic throughout your views, you centralize it in one reusable protocol.
This approach scales beautifully as your app grows—you can add more computed properties to the protocol extension (like isLandscape, isPortrait, or device-specific checks) and all conforming views get them automatically.
Give it a try in your next universal app. Your future self will thank you when you need to adjust adaptive behavior across dozens of views!
Have you found other elegant patterns for handling adaptive layouts in SwiftUI? I'd love to hear about them!



Comments