We hit a roadblock in the previous episode. The CurrentConditionsViewModel struct needs access to a Store object, but it is tedious to pass a Store object from one view model to the next. In this episode, we use a dependency injection library, Swinject, to make this much less of a problem.

Adding Swinject to the Project

We start by installing the Swinject library using the Swift Package Manager. Select the project in the Project Navigator on the left. Select the project in the Project section and open the Package Dependencies tab at the top.

Installing the Swinject Library

The Package Dependencies tab shows you an overview of the Swift packages the project depends on. Click the + button at the bottom of the table to add a Swift package. Enter the package URL of the Swinject library in the search field in the top right. We leave the configuration as is. Click Add Package in the bottom right.

Installing the Swinject Library

Select the Swinject package product and make sure Add to Target is set to Thunderstorm. Click Add Package to add the package product to the Thunderstorm target.

Installing the Swinject Library

Note that Swinject is now listed as a dependency in the Package Dependencies section in the Project Navigator on the left.

Installing the Swinject Library

Using a Dependency Injection Container

A dependency injection container is an object that manages the services an application uses. You can register services with the dependency injection container so that other objects can access the services they need to do their work.

The key responsibility of a dependency injection container is the promotion of inversion of control. This simply means that the dependency injection container creates the services other objects need to do their work so that those objects don't need to create and manage those services. One of the key benefits is the decoupling of dependencies in the project, which improves reusability and testability. It also allows for your code to be more modular.

Let's put this into practice. Add a Swift file to the Extensions group and name it Container+Helpers.swift. Add an import statement for the Swinject library at the top and declare an extension for the Container class.

import Swinject

extension Container {
	
}

Declare a static, constant property named shared. We create a Container instance and store a reference to it in the static shared property. We create a singleton Container instance, which can be accessed through the static shared property.

import Swinject

extension Container {

    // MARK: - Properties

    static let shared = Container()

}

Declare a static, computed, variable property named store of type Store. In the body of the computed property, we invoke the resolve(_:) method on the Container singleton, passing in the Store type. The resolve(_:) method returns an optional, which isn't convenient to work with so we use the exclamation mark to force unwrap it. This is one of the few instances in which I use the exclamation mark to force unwrap an optional. If the resolve(_:) method doesn't return a Store object, we introduced a bug we need to fix.

import Swinject

extension Container {

    // MARK: - Properties

    static let shared = Container()

    // MARK: - Services

    static var store: Store {
        shared.resolve(Store.self)!
    }

}

Registering Services with the Dependency Injection Container

We can only access the store through the dependency injection container if it is registered with the dependency injection container. Open ThunderstormApp.swift and add an import statement for the Swinject library at the top. Declare a private helper method named registerServices().

import SwiftUI
import Swinject

@main
struct ThunderstormApp: App {

    // MARK: - App

    var body: some Scene {
    	...
    }

    // MARK: - Helper Methods

    private func registerServices() {
        
    }

}

In the body of the registerServices() method, we access the Container singleton through its static shared property. We register a service with the dependency injection container by invoking the register(_:factory:) method. The first argument is the type of the service, the Store type. The second argument is a closure that returns the service. In this example, we return a reference to the UserDefaults singleton.

// MARK: - Helper Methods

private func registerServices() {
    Container.shared.register(Store.self) { _ in
        UserDefaults.standard
    }
}

We invoke the registerServices() method in the initializer of the ThunderstormApp struct. We want to make sure the service is registered with the dependency injection container before an object depending on the service attempts to access it.

import SwiftUI
import Swinject

@main
struct ThunderstormApp: App {

    // MARK: - Initialization

    init() {
        registerServices()
    }
    
    // MARK: - App

    var body: some Scene {
        WindowGroup {
            LocationsView(
                viewModel: .init(
                    store: UserDefaults.standard,
                    weatherService: WeatherClient()
                )
            )
        }
    }

    // MARK: - Helper Methods

    private func registerServices() {
        Container.shared.register(Store.self) { _ in
            UserDefaults.standard
        }
    }

}

Accessing Services through the Dependency Injection Container

It is important that we only access the UserDefaults singleton through the dependency injection container. We no longer pass a reference to the UserDefaults singleton to the initializer of the LocationsViewModel class. Instead we access the Store singleton through the dependency injection container.

// MARK: - App

var body: some Scene {
    WindowGroup {
        LocationsView(
            viewModel: .init(
                store: Container.store,
                weatherService: WeatherClient()
            )
        )
    }
}

Open LocationViewModel.swift and add an import statement for the Swinject library at the top.

import Swinject
import Foundation

@MainActor
final class LocationViewModel: ObservableObject {

	...

}

Navigate to the start() method. We need to update the initialization of the CurrentConditionsViewModel object. The initializer accepts three arguments, a Location object, a reference to the Store singleton, and a CurrentConditions object. As you can see, we access the Store singleton through the dependency injection container.

// MARK: - Public API

func start() async {
    do {
        let data = try await weatherService.weather(for: location)

        state = .data(
            currentConditionsViewModel: .init(
                location: location,
                store: Container.store,
                currently: data.currently
            ),
            forecastViewModel: .init(forecast: data.forecast)
        )
    } catch {
	    ...
    }
}

Open CurrentConditionsView.swift and navigate to the CurrentConditionsView_Previews struct. We need to update the initialization of the CurrentConditionsViewModel object. We pass a Location object as the first argument. The second argument we pass to the initializer is a PreviewStore object.

struct CurrentConditionsView_Previews: PreviewProvider {
    static var previews: some View {
        CurrentConditionsView(
            viewModel: .init(
                location: .preview,
                store: PreviewStore(),
                currently: WeatherData.preview.currently
            )
        )
    }
}

Build and Run

Build and run the application in the simulator. Tap the Add Location button. Enter the name of a town or city in the text field at the top and add a location. Tap the location in the LocationsView to navigate to the LocationView. Tap the delete button to delete the location.

What's Next?

We used Swinject in this episode, but know that there are plenty of other options to choose from, including a solution you build yourself.