top of page

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?

  1. The protocol requires conforming types to provide verticalSizeClass and horizontalSizeClass

  2. The protocol extension provides default implementations for isRegularSize and isCompactSize

  3. 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


bottom of page