SwiftUI - Creating a custom @Environment

SwiftUI enables us to access common pieces of information throughout the app using @Environment objects avoiding the cumbersomeness of passing data through the views.

There are many built-in environments such as openURL, colorScheme or managedObjectContext that are defined in the Swift standard library as part of an extension of the EnvironmentValues.

extension EnvironmentValues {
    public var managedObjectContext: NSManagedObjectContext
}

While these built-in objects are very convenient, SwiftUI gives us the ability to create tailor-made environments to meet our needs.

In this demo, we’ll be creating a tracking environment object to follow different states and user actions for analytic purposes.

The first step is to create a struct that will conform to the EnvironmentKey protocol with only one requirement: a default value.

struct TrackStateKey: EnvironmentKey {
    static let defaultValue: TrackStateAction = .init(state: .loading)
}

Then, we need to extend the EnvironmentValues struct and define a property that will be exposed to our views.
Just like SwiftUI’s built-in environments, we use KeyPath to access or mutate the default value static property.

extension EnvironmentValues {
    var trackState: TrackStateAction {
        get { self[TrackStateKey.self] }
        set { self[TrackStateKey.self] = newValue }
    }
}

Let’s create a TrackState enum to follow the different states and actions.

enum TrackState {
    case loading
    case appear(String = .init()) 
    case create(String = .init())
    case tap(String = .init())
    case error(Error)
}

The TrackStateAction class encapsulates the core logic of our environment.

class TrackStateAction {
    var state: TrackState
    
    init(state: TrackState) {
        self.state = state
    }
    
    func callAsFunction(_ newState: TrackState) {
        state = newState
        sendState()
    }
    
    private func sendState() {
        switch state {
        case .appear(let value), .create(let value), .tap(let value):
            /// send value to back-end analytics
        case .loading:
            /// send loading state to back-end analytics
        case .error(let error):
            /// send error state to back-end analytics
        }
    }
}

At last, in order to follow specific events such as the creation of a view, we need to create a custom modifier through the View extension:

extension View {
    func trackState(_ newState: TrackState) -> some View {
        environment(\.trackState, TrackStateAction(state: newState))
    }
}

The environment function takes two arguments that is your WritableKeyPath (which is the property from the EnvironmentValues extension) and the new value.

func environment<V>(_ keyPath: WritableKeyPath<EnvironmentValues, V>, _ value: V) -> some View

We can now use our custom environment object and track app states and actions inside our SwiftUI code.
In a real-life app, each state would be initialized with a specific context such as .tap("Custom view - Log in").

struct CustomView: View {
    @Environment(\.openURL) var openURL
    @Environment(\.trackState) var trackState
    
    var body: some View {
        Button {
            login()
            trackState(.tap())
        } label: {
            Text("Log in")
        }
        .trackState(.create())
        .onAppear {
            trackState(.appear())
        }
    }
}

To enable analytics for the entire view hierarchy — which is most likely the case — we would inject the environment at the app level.

@main
struct CustomApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.trackState, .init(state: .loading))
        }
    }
}

Conclusion

By leveraging SwiftUI’s powerful APIs, @Environment objects allow us to create an elegant way of encapsulating common behaviours within the app.