SwiftUI — Creating a custom @Environment

Jullian Mercier
3 min readOct 25, 2020

--

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` to manage data storage with Core Data.

All these environments 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.

--

--

Jullian Mercier

Senior iOS engineer. jullianmercier.com. @jullian_mercier. Currently looking for new job opportunities.