Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

Using @Environment in SwiftUI to link Swift Package dependencies

The @Environment property wrapper in SwiftUI allows you to read values from a view’s environment. You’re able to configure an environment value yourself or make use of the default available values.

Please read my article on Property Wrappers before diving into this one. Note that this is a different wrapper than @EnvironmentObject, which I explain in this article. Today, we’re going to see how we can connect @Observable conforming objects with Swift Packages and prevent unnecessary dependencies.

How to use the @Environment Property Wrapper

The @Environment Property Wrapper allows you to read default values from SwiftUI’s environment or custom-injected objects. You can discover default values by using the autocomplete functionality:

Use the @Environment property wrapper to read values from a view's environment.
Use the @Environment property wrapper to read values from a view’s environment.

For example, you can read the color scheme value and automatically get your view updated when the color scheme changes:

struct ColorSchemeView: View {
    @Environment(\.colorScheme) private var colorScheme
    
    var body: some View {
        if colorScheme == .dark { // Checks the wrapped value from the environment Property Wrapper.
            Text("Dark mode enabled")
        } else {
            Text("Light mode enabled")
        }
    }
}

Injecting custom objects into a view’s environment

You can make the most out of the @Environment property wrapper by injecting custom observable objects. For example, you might have an observable Theme provider instance:

@Observable
final class ThemeProvider {
    var currentTheme: Theme = Theme(primaryColor: .green)
}

You can use the theme provider to style your views based on the selected theme:

struct ThemedView: View {
    
    @Environment(ThemeProvider.self) private var themeProvider: ThemeProvider
    
    var body: some View {
        Text("This text is using the theme's primary color")
            .foregroundStyle(themeProvider.currentTheme.primaryColor)
    }
}

Note that we’re reading the ThemeProvider instance from our ThemedView‘s environment. Since we’ve not defined it as optional, our themed view requires the value to be present. In case it’s not, you’ll run into the following error:

Thread 1: Fatal error: No Observable object of type ThemeProvider found. A View.environmentObject(_:) for ThemeProvider may be missing as an ancestor of this view.

You can solve this error by either providing a default value after turning themeProvider into an optional:

struct ThemedView: View {
    
    @Environment(ThemeProvider.self) private var themeProvider: ThemeProvider?
    
    var body: some View {
        Text("This text is using the theme's primary color")
            .foregroundStyle(themeProvider?.currentTheme.primaryColor ?? .accentColor)
    }
}

However, in this case, you want to be sure our view follows the currently selected theme. Therefore, we will inject the theme provider properly:

@main
struct EnvironmentObjectsExampleApp: App {


    let themeProvider = ThemeProvider()
    
    var body: some Scene {
        WindowGroup {
            ThemedView()
                /// Inject the theme provider into `ThemedView`'s environment.
                .environment(themeProvider)
        }
    }
}

Like with @EnvironmentObject, we can now access the theme provider within any ThemedView child view.

Stay updated with the latest in SwiftUI

Join 20,010 Swift developers in our exclusive newsletter for the latest insights, tips, and updates. Don't miss out – join today!

You can always unsubscribe, no hard feelings.

Preventing unnecessary Swift Package Dependencies using SwiftUI’s environment

Many developers like to build individual features in isolated Swift packages. You might have even created packages that only contain SwiftUI views to improve the performance of SwiftUI Previews.

Separate modules are great for separation of concern, but it does require you to think about ways to connect things like tracking services. Will you create and connect another tracking package dependency, or will you work with delegates?

My preferred solution is to keep each module as stupid as possible. My packages will only post events that happen inside, but they will have no clue what will happen after they post the event. There’s no need to know whether it’s connecting to a tracking package. Instead, it will send events and stay isolated as much as possible. This means less tangled code and fewer dependencies to maintain.

Using an event monitor to connect with dependencies

I prefer to connect dependencies using an event monitor injected using @Environment. For RocketSim, I recently created a new package to support a new feature that allows you to keep track of Xcode build count and duration. The package has the name RocketSimInsights and has no dependencies.

To connect views and objects with outside dependencies, I’m using the following event monitor protocol:

/// Defines a `RocketSim Insights` events monitor handling upload related events.
/// Can be used to monitor and track events related to uploads going through `RocketSim Insights`.
public protocol RocketSimInsightsEventMonitor: Sendable {
    /// Post an event to the monitor related to a `RocketSim Insights` interaction.
    /// - Parameters:
    ///   - event: The `RocketSimInsightsEvent` that just happened.
    ///   - properties: Any related properties for the event.
    func post(event: RocketSimInsightsEvent, properties: [String: Codable & Sendable])
}

Any dependency can make use of this protocol and register with my package. To support multiple observers, I’ve created a CompositeRocketSimInsightsEventMonitor instance:

/// An internal type to bring together all registered monitors and send events from a centralized place.
@Observable
final class CompositeRocketSimInsightsEventMonitor: RocketSimInsightsEventMonitor {
    let queue = DispatchQueue(label: "rocketsim.insights.composite.event.monitor", qos: .utility)

    let monitors: [RocketSimInsightsEventMonitor]

    init(monitors: [RocketSimInsightsEventMonitor]) {
        self.monitors = monitors
    }
    
    func post(event: RocketSimInsightsEvent, properties: [String: Codable & Sendable] = [:]) {
        guard !monitors.isEmpty else { return }
        queue.async { [weak self] in
            guard let self else { return }
            for monitor in self.monitors {
                monitor.post(event: event, properties: properties)
            }
        }
    }
}

The composite monitor posts events asynchronously to all other registered monitors. After injecting it into my view’s environment, I can start using it to post events to any dependencies out there.

From within the views, I post events like switchedRange and switchedScheme. I’ve also created a view modifier to redact certain charts for non-Pro users. Within this view modifier, I’m posting an event to let dependencies know that the user tapped the ‘become pro’ button:

struct ChartRedactModifier: ViewModifier {
    
    /// Read the event monitor from the view modifier environment
    @Environment(CompositeRocketSimInsightsEventMonitor.self) private var eventMonitor: CompositeRocketSimInsightsEventMonitor?
    let redacted: Bool
    let chartTitle: String
    
    func body(content: Content) -> some View {
        content.opacity(redacted ? 0.3 : 1.0)
            .redacted(if: redacted)
            .overlay {
                if redacted {
                    VStack {
                        Text("This range of data is only available in RocketSim Pro")
                        if let eventMonitor {
                            Button(action: {
                                /// Post an event when the button is pressed.
                                eventMonitor.post(event: .clickedBecomeProInsideChart, properties: ["chart": chartTitle])
                            }, label: {
                                Text("Become Pro")
                            })
                            .buttonStyle(.plain)
                        }
                    }
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            }
    }
}

The great thing about this approach: I can use the events for tracking but also to trigger other actions like presenting my sales page:

import RocketSimInsights

struct BuildInsightsEventMonitor: RocketSimInsightsEventMonitor {
    func post(event: RocketSimInsightsEvent, properties: [String: any Codable & Sendable]) {
        Tracker.trackEvent(.build_insights, action: event.rawValue, properties: properties)

        if event == .clickedBecomeProInsideChart {
            Task { @MainActor in
                SalesWindowPresenter.shared.presentSalesWindow(source: .buildInsightsChart)
            }
        }
    }
}

This results in an isolated package without a clue about how events are used. It remains stable, independent, fast, and flexible.

Conclusion

SwiftUI’s @Environment property wrapper allows you to read default and custom values from a view’s environment. Using it smartly, you can connect Swift packages without adding all kinds of package dependencies.

If you want to improve your SwiftUI knowledge, even more, check out the SwiftUI category page. Feel free to contact me or tweet me on Twitter if you have any additional tips or feedback.

Thanks!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.