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
Your smart home assistant.
Injecting interfaces
From my backend experience, I know two tools that are extremely powerful for bringing flexibility and decoupling:
- Interfaces
: They allow defining the minimal requirements of methods to expose for a specific feature.
It also brings some abstraction over the reality of the implementation.
In my opinion, it’s a good thing to remove implementation details and dependencies (HomeKit objects, RevenueCat models…) from the views that reflect that info on the screen. - Dependency injection
: It removes the responsibility of knowing how to create a specific object.
This is important because if we want to preview things, we must be able to inject different states for that view, while keeping the code as clean as possible.
Dependency Injection unlocks the possibility to inject different implementations of our protocol, which will mock behaviors and allow us to simulate situations that would otherwise be difficult to reproduce (all possible subscription states, HomeKit state, Location, etc.).
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:
- Authorization status for HomeKit, with a custom enum
- The home(s) of the user, with a custom struct representing a home
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:
- or
@Observable
objects, Swift Observability only refreshes the view when a used property is updated. - or
ObservableObject
, only the views that reference the@Environment
key are going to be refreshed when one of the @Published values changes. But as we can reference the Environment deep into the view hierarchy, we can build small ObservableObjects and only reference them when required.
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
Your smart home assistant.
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.