Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 087 / 100

Environment values in SwiftUI for Swift & iOS developers

Pass data down without parameters.

Expona Admin7 min read

Environment values in SwiftUI for Swift & iOS developers

Environment values in SwiftUI are a powerful mechanism for passing data down the view hierarchy without drilling parameters through every intermediate view. Once you understand how to use built-in environment values and create your own custom ones, you'll write cleaner, more maintainable view code that stays decoupled from the views that consume it.

Why environment values matter

In traditional imperative iOS development, we'd pass configuration or state through initializers: one view passes it to the next, which passes it to the next. This prop drilling becomes a maintenance burden as your view hierarchy deepens. Environment values solve this by making data available throughout a subtree without explicit parameter passing.

Think of environment values like context in a React app or dependency injection containers in backend services. They're especially useful for app-wide settings, user preferences, navigation state, and shared model objects that multiple views need access to.

Built-in values — colorScheme, dismiss, openURL and more

SwiftUI ships with dozens of environment values that control appearance, navigation, and system integration. We use these constantly, often without thinking about where they come from.

colorScheme tells you whether the user prefers light or dark mode:

swift
struct ThemeAwareCard: View {
  @Environment(\.colorScheme) var colorScheme
  
  var body: some View {
    VStack {
      Text("Current scheme: \(colorScheme == .dark ? "dark" : "light")")
    }
    .padding()
    .background(colorScheme == .dark ? Color.black : Color.white)
  }
}

dismiss lets you programmatically close a sheet or navigate back without exposing navigation state to child views:

swift
struct DetailView: View {
  @Environment(\.dismiss) var dismiss
  
  var body: some View {
    NavigationStack {
      VStack {
        Text("Detail content")
        Button("Done") {
          dismiss()
        }
      }
    }
  }
}

openURL opens URLs without importing the system's URL handling directly:

swift
struct LinkButton: View {
  @Environment(\.openURL) var openURL
  
  var body: some View {
    Button("Visit Documentation") {
      if let url = URL(string: "https://developer.apple.com") {
        openURL(url)
      }
    }
  }
}

Other common built-in values include isEnabled, controlSize, locale, calendar, timeZone, and layoutDirection. Each one follows the same pattern: declare it with @Environment, specifying the keypath, and SwiftUI injects the current value.

Custom values — @Entry macro defines custom environment keys

Starting in iOS 17 and macOS 14, the @Entry macro makes creating custom environment keys straightforward. Before this, you had to implement EnvironmentKey protocol manually. The macro cuts boilerplate significantly.

Imagine we're building a team collaboration app where we need to pass the current user's permission level throughout the view hierarchy:

swift
extension EnvironmentValues {
  @Entry var userPermissionLevel: PermissionLevel = .viewer
}

enum PermissionLevel {
  case viewer
  case editor
  case admin
}

That's it. The macro generates the key and default value for us. Now we inject it at the root of our view tree:

swift
@main
struct CollaborationApp: App {
  @State private var currentUser = User(name: "Alice", permissionLevel: .editor)
  
  var body: some Scene {
    WindowGroup {
      DocumentListView()
        .environment(\.userPermissionLevel, currentUser.permissionLevel)
    }
  }
}

Any view in the tree can now access this value without receiving it as a parameter:

swift
struct DocumentRow: View {
  @Environment(\.userPermissionLevel) var permissionLevel
  let document: Document
  
  var body: some View {
    HStack {
      Text(document.title)
      Spacer()
      if permissionLevel == .editor || permissionLevel == .admin {
        Button("Edit") {
          // Handle edit
        }
      }
    }
  }
}

This approach scales well. If you need to add another piece of app-wide configuration—say, a feature flag or analytics ID—you extend EnvironmentValues with another @Entry property.

Inject models — Share @Observable models with .environment()

One of the most powerful patterns is injecting @Observable view models or data models into the environment. This lets deeply nested views react to model changes without passing view model references through every intermediate view.

Here's a shopping cart example. Our cart model is observable:

swift
@Observable
class ShoppingCart {
  var items: [CartItem] = []
  var total: Double {
    items.reduce(0) { $0 + $1.price * Double($1.quantity) }
  }
  
  func add(_ item: CartItem) {
    items.append(item)
  }
  
  func remove(_ item: CartItem) {
    items.removeAll { $0.id == item.id }
  }
}

We inject it at the app level and make it available to all views:

swift
@main
struct StoreApp: App {
  @State private var cart = ShoppingCart()
  
  var body: some Scene {
    WindowGroup {
      ContentView()
        .environment(cart)
    }
  }
}

Now a view deep in the product hierarchy can access and modify the cart without receiving it as a parameter:

swift
struct ProductDetailView: View {
  @Environment(ShoppingCart) var cart
  let product: Product
  
  var body: some View {
    VStack {
      Text(product.name)
      Text("$\(product.price, format: .currency(code: "USD"))")
      
      Button("Add to Cart") {
        cart.add(CartItem(product: product, quantity: 1))
      }
    }
  }
}

struct CartBadge: View {
  @Environment(ShoppingCart) var cart
  
  var body: some View {
    VStack {
      if !cart.items.isEmpty {
        Badge(value: cart.items.count)
      }
      Image(systemName: "cart")
    }
  }
}

Because ShoppingCart is @Observable, changes to its properties automatically trigger view refreshes. The cart badge updates when items are added, and the detail view's button can modify the cart directly. This pattern eliminates callback chains and parent-to-child state bindings that clutter view hierarchies.

Previews — Override environment values per preview

One of the best uses for custom environment values is testing views in different states within SwiftUI previews. Rather than creating multiple mock view models, you override environment values to show how your UI adapts.

Let's preview our permission-based UI in different permission levels:

swift
#Preview("Viewer", traits: .sizeThatFitsLayout) {
  DocumentRow(document: Document(id: 1, title: "Q4 Planning"))
    .environment(\.userPermissionLevel, .viewer)
}

#Preview("Editor", traits: .sizeThatFitsLayout) {
  DocumentRow(document: Document(id: 1, title: "Q4 Planning"))
    .environment(\.userPermissionLevel, .editor)
}

#Preview("Admin", traits: .sizeThatFitsLayout) {
  DocumentRow(document: Document(id: 1, title: "Q4 Planning"))
    .environment(\.userPermissionLevel, .admin)
}

Or test your cart badge in empty and populated states:

swift
#Preview("Empty Cart") {
  CartBadge()
    .environment(ShoppingCart())
}

#Preview("Full Cart") {
  let cart = ShoppingCart()
  cart.items = [
    CartItem(id: 1, name: "Widget", price: 19.99, quantity: 2),
    CartItem(id: 2, name: "Gadget", price: 29.99, quantity: 1)
  ]
  
  return CartBadge()
    .environment(cart)
}

This approach beats creating separate mock models for each preview. You keep your preview code focused on what changes, and the view sees a real model instance behaving exactly as it would in production.

Common mistakes

  • Forgetting the default value in @Entry. Every custom environment key needs a sensible default. If you omit it, the macro won't compile. Think about what value makes sense when the environment hasn't been explicitly set.
  • Passing too much through environment. Just because you can inject anything doesn't mean you should. Use environment values for configuration, permissions, and shared state. Passing small, frequently-changing values through parameters is often clearer.
  • Modifying environment values mid-tree. You can override an environment value at any level with .environment(), but doing so silently in nested views confuses data flow. Keep environment overrides at logical boundaries—app root, sheet presentation, navigation transitions.
  • Not considering testability. If a view only reads environment values and never receives them as parameters, make sure your tests can inject those values. Create mock environment setups in your test helpers.
  • Using @Environment on non-@Observable models. If your injected model isn't observable, SwiftUI won't track changes, and views won't re-render. Always mark shared state models with @Observable.

Key takeaways

  • Environment values eliminate parameter drilling by making data available throughout a view subtree.
  • SwiftUI provides built-in environment values like colorScheme, dismiss, and openURL; access them with @Environment.
  • Use the @Entry macro to define custom environment keys with minimal boilerplate in iOS 17+.
  • Inject @Observable models into the environment to share reactive state without prop drilling or callback chains.
  • Override environment values in previews to test views in different states and configurations without creating multiple mock objects.
  • Reserve environment values for configuration, permissions, and shared state; keep frequently-changing values as parameters for clarity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles