Using custom environment values in SwiftUI

Apr 25, 2024#swiftui

Environment values are used for passing data down the view hierarchy implicitly, instead of passing explicitly through each view’s initializer. SwiftUI automatically updates the view whenever these values change, ensuring that the UI stays in sync with the application state.

Environment can represent various aspects of the application state, configuration, or user preferences. To access an environment value within a view, you use the @Environment property wrapper followed by the corresponding environment key or data type.

There are many built-in environment values provided to help manage and customize the behavior and appearance of views like scheme, current locale, current layout direction, default font, accessibility settings, etc.

import SwiftUI

struct ContentView: View {
    @Environment(\.layoutDirection) var layoutDirection
    @Environment(\.colorScheme) var colorScheme
    @Environment(\.locale) var locale
    
    var body: some View {
        VStack {
            Text("Layout Direction: \(layoutDirection)")
            Text("Color Scheme: \(colorScheme)")
            Text("Locale: \(locale.identifier)")
        }
    }
}

By creating custom environment values, you can integrate seamlessly with SwiftUI’s ecosystem and leverage the same patterns and conventions used by the framework. Here are 3 main ways to inject and read your environment values:

  • Using value types and @Environment (iOS 13.0+)
  • Using ObservableObject and @EnvironmentObject (iOS 13.0+)
  • Using @Observable and @Environment (iOS 17+)

Using value types and @Environment

Define a new key for your custom value by creating a type that conforms to the EnvironmentKey protocol. The key should have a default value that corresponds to your custom data type.

Extend EnvironmentValues using an extension to introduce your custom key and value. Implement the getter and setter for your custom value using the key you defined earlier.

You can inject value using environment() modifier. To make it easier for users to set the new value, you can create a dedicated view modifier.

After injected in parent views, you can access in child views using key path just like system-defined values.

import SwiftUI

struct MyIntKey: EnvironmentKey {
    static let defaultValue: Int = 0
}

extension EnvironmentValues {
    public var myIntValue: Int {
        get { self[MyIntKey.self] }
        set { self[MyIntKey.self] = newValue }
    }
}

struct MyView: View {
    @Environment(\.myIntValue) private var myIntValue
    
    var body: some View {
        Text("myIntValue: \(myIntValue)")
    }
}

struct ContentView: View {
    var body: some View {
        MyView()
            .environment(\.myIntValue, 42)
    }
}

Using ObservableObject and @EnvironmentObject

An ObservableObject is a protocol in Combine framework that allows an object to be observed for changes. Properties marked with @Published will automatically notify views when their values change.

Use the .environmentObject() modifier to inject an observable object into the environment, then use EnvironmentObject property wrapper to read that object from a view’s environment.

import SwiftUI

class AppSettings: ObservableObject {
    @Published var isDarkMode: Bool = false
}

struct SettingsView: View {
    @EnvironmentObject private var appSettings: AppSettings
    
    var body: some View {
        VStack {
            Text("Dark Mode is \(appSettings.isDarkMode ? "On" : "Off")")
            Button("Toggle Dark Mode") {
                appSettings.isDarkMode.toggle()
            }
        }
    }
}

struct ContentView: View {
    @StateObject private var appSettings = AppSettings()
    
    var body: some View {
        SettingsView()
            .environmentObject(appSettings)
    }
}

Using @Observable and @Environment

Since iOS 17, SwiftUI introduced @Observable macro as part of Observation framework to transform your type into something that can be observed. Unlike the old way using the ObservableObject protocol, you don’t need any property wrappers (such as @Published) to make this work. SwiftUI automatically tracks changes within an instance of an observable type.

If you already import SwiftUI, there’s no need to import Observation separately, as it’s included within SwiftUI.

You then following similar steps to inject and access observable object using a key path.

import SwiftUI

@Observable
class AppSettings {
    var isDarkMode: Bool = false
}

struct AppSettingsKey: EnvironmentKey {
    static let defaultValue: AppSettings = AppSettings()
}

extension EnvironmentValues {
    var appSettings: AppSettings {
        get { self[AppSettingsKey.self] }
        set { self[AppSettingsKey.self] = newValue }
    }
}

struct SettingsView: View {
    @Environment(\.appSettings) private var appSettings
    
    var body: some View {
        VStack {
            Text("Dark Mode is \(appSettings.isDarkMode ? "On" : "Off")")
            Button("Toggle Dark Mode") {
                appSettings.isDarkMode.toggle()
            }
        }
    }
}

struct ContentView: View {
    @State private var appSettings = AppSettings()
    
    var body: some View {
        SettingsView()
            .environment(\.appSettings, appSettings)
    }
}

Instead of defining a custom key, you can access your observable object using its data type.

import SwiftUI

@Observable 
class AppSettings {
    var isDarkMode: Bool = false
}

struct SettingsView: View {
    @Environment(AppSettings.self) private var appSettings
    
    var body: some View {
        VStack {
            Text("Dark Mode is \(appSettings.isDarkMode ? "On" : "Off")")
            Button("Toggle Dark Mode") {
                appSettings.isDarkMode.toggle()
            }
        }
    }
}

struct ContentView: View {
    @State private var appSettings = AppSettings()
    
    var body: some View {
        SettingsView()
            .environment(appSettings)
    }
}