SwiftUI Previews-based architecture

Like I said in my last Indie Diary, I knew when starting what would become Omee that I’d need a very flexible architecture for my app, built with SwiftUI previews in mind.

SwiftUI Previews are a tremendous improvement for DX since their introduction back in 2019. This time, I needed them more than ever, so I started experimenting with possible architectures for them.

Omee app icon

Omee

Your smart home assistant.

Learn more

Injecting interfaces

From my backend experience, I know two tools that are extremely powerful for bringing flexibility and decoupling:

SwiftUI Dependency Injection

Interfaces are called protocol in Swift and are built-in. It’s then very easy to define one that is agnostic of the framework it uses.

In the following example, here is a HomeKit-agnostic way to get:

protocol HomeProvider: Sendable {
    @MainActor var status: HomeAuthorizationStatus { get }
    @MainActor var homes: [Home] { get }
}

enum HomeAuthorizationStatus {
    case undetermined
    case restricted
    case authorized
}

struct Home {
    let id: UUID
    let name: String
}

The key point here is that we own those implementations. Therefore, building views that reflect those objects is stronger and decoupled from possible HomeKit evolutions.

Since we have decoupled, we need to make a concrete implementation of that protocol, one that will use HomeKit. Here, I use the new Observability feature that is iOS 17 only, but using ObservableObject would work as well.

@MainActor
@Observable
final class HomeKitProvider: HomeProvider {
    ...
}

Now, let’s move to dependency injection. The closest tool we have in SwiftUI is the Environment values.

They come with a default value: the live one used by the app, but can then be overridden with the .environment modifier.

struct HomeProviderKey: EnvironmentKey {
    static let defaultValue: HomeProvider = HomeKitProvider()
}

extension EnvironmentValues {
    var homeProvider: HomeProvider {
        get { self[HomeProviderKey.self] }
        set { self[HomeProviderKey.self] = newValue }
    }
}

Now we can use our protocol within our views:

struct HomeView: View {
    @Environment(\.homeProvider)
    private var homeProvider

    var body: some View {
        switch homeProvider.status {
            ...
        }
    }
}

Previewing different states

Since we own the protocol and the struct/enum, we can create a custom dedicated implementation for our previews:

struct PreviewHomeProvider: HomeProvider {
    let status: HomeAuthorizationStatus
    let homes: [Home]
}

extension HomeProvider where Self == PreviewHomeProvider {
    static var preview: Self {
        PreviewHomeProvider(status: .authorized, homes: [.previewHome])
    }
    static var multiple: Self {
        PreviewHomeProvider(status: .authorized, homes: [.previewHome, .otherHome])
    }
    static var noHome: Self {
        PreviewHomeProvider(status: .authorized, homes: [])
    }
    static var restricted: Self {
        PreviewHomeProvider(status: .restricted, homes: [])
    }
    static var undetermined: Self {
        PreviewHomeProvider(status: .undetermined, homes: [])
    }
}

And inject those in dedicated previews to build a view that will reflect each possibility:

#Preview("Standard") {
    HomeView()
        .environment(\.homeProvider, .standard)
}
#Preview("Multiple homes") {
    HomeView()
        .environment(\.homeProvider, .multiple)
}
#Preview("No home") {
    HomeView()
        .environment(\.homeProvider, .noHomeAvailable)
}
#Preview("Restricted") {
    HomeView()
        .environment(\.homeProvider, .restricted)
}
#Preview("Undetermined") {
    HomeView()
        .environment(\.homeProvider, .undetermined)
}

Bonus: Live switching between states

In the example above, we’ve been using a struct with constants to mock my HomeKit implementation. But how to test when you switch from one state to another? This can be handy if you need to test the transition between states.

We could add complexity by transforming those structs into observable classes. But instead, I find it more practical to just switch the environment value on the fly, with an enum and a @State:

private struct HomeViewPreview: View {
    enum HomeState: String, CaseIterable {
        case standard
        case multiple
        case noHome
        case restricted
        case undetermined

        var value: HomeProvider {
            return switch self {
            case .standard: .standard
            case .multiple: .multiple
            case .noHome: .noHome
            case .restricted: .restricted
            case .undetermined: .undetermined
            }
        }
    }

    @State private var home: HomeState = .standard

    var body: some View {
        ZStack(alignment: .bottom) {
            RootView()
                .environment(\.homeProvider, home.value)

            Picker(selection: $home) {
                ForEach(HomeState.allCases, id: \.self) { provider in
                    Text(provider.rawValue)
                }
            } label: {
                EmptyView()
            }
            .pickerStyle(.menu)
        }
    }
}

#Preview("Live") {
    HomeViewPreview()
}

Performance considerations

Regarding performance, this is close to being one of the best possible patterns I could think of:

Regarding memory and data synchronization, using SwiftUI Environment values ensures that our object is uniquely shared across all the views.

The main limitation here is that dependencies need to be standalone. We cannot access a dependency outside of a SwiftUI view. But I see this as an opportunity to avoid spaghetti code more than a real issue for a full SwiftUI app.

Omee app icon

Omee

Your smart home assistant.

Learn more

Don’t miss a thing!

Don't miss any of my indie dev stories, app updates, or upcoming creations!
Stay in the loop and be the first to experience my apps, betas and stories of my indie journey.

Thank you for registering!
You’ll retrieve all of my latest news!

Anti-bot did not work correctly.
Can you try again?

Your email is sadly invalid.
Can you try again?

An error occurred while registering.
Please try again