ContainerValues: Per-Item Customization in SwiftUI Containers

ContainerValues: Per-Item Customization in SwiftUI Containers

If you've ever needed one child view in a SwiftUI container to look or behave differently from its siblings, you've probably reached for @Environment — and paid the price. Environment values propagate to every descendant in the subtree, not just the direct children of your container. Set a tint color on a parent and every nested view picks it up, even the ones you didn't intend to affect.

iOS 18 introduced ContainerValues, a keyed storage system designed specifically for this problem. Values are scoped to the container that reads them: children can stamp whatever metadata they want onto themselves, and only the immediate owning container sees it. Nothing bleeds sideways, nothing leaks downward.

The Core Idea

Think of ContainerValues as per-subview annotations. A child view declares its intent — "I want a highlighted badge", "render me as compact" — using a modifier backed by a container value. The container reads that declaration when it lays out the child, and acts on it. Consumers of the container never see the value.

The contrast with @Environment is precise:

  • Environment: pushed down from a parent, read by any descendant at any depth.
  • ContainerValues: declared by a child, read only by the one container that directly owns that child.

Declaring a Container Value

Extend ContainerValues using the @Entry macro — the same macro that handles EnvironmentValues, FocusValues, and Transaction. No separate key struct needed.

import SwiftUI

// 1. Declare the value type
enum BadgeStyle { case none, highlighted, muted }

// 2. Register it on ContainerValues
extension ContainerValues {
    @Entry var badgeStyle: BadgeStyle = .none
}

// 3. Expose it as a View modifier for call-site ergonomics
extension View {
    func badgeStyle(_ style: BadgeStyle) -> some View {
        containerValue(\.badgeStyle, style)
    }
}

The containerValue(_:_:) modifier attaches the value to the view within a container context. Its signature:

func containerValue<T>(
    _ keyPath: WritableKeyPath<ContainerValues, T>,
    _ value: T
) -> some View

Reading Values Inside a Container

Custom containers use ForEach(subviewOf:) or Group(subviewsOf:) to iterate their resolved subviews. Each resolved subview exposes a containerValues property.

struct CardContainer<Content: View>: View {
    @ViewBuilder let content: Content

    var body: some View {
        // ForEach(subviewOf:) iterates one subview at a time
        VStack(spacing: 12) {
            ForEach(subviewOf: content) { subview in
                let style = subview.containerValues.badgeStyle
                CardView(badgeStyle: style) {
                    subview
                }
            }
        }
    }
}

// Usage — each child carries its own badge intent
CardContainer {
    Text("Free tier").badgeStyle(.none)
    Text("Pro").badgeStyle(.highlighted)
    Text("Trial").badgeStyle(.muted)
}

Each card reads its own value. A .highlighted badge on the "Pro" row has no effect on "Free tier" or "Trial" — not even via a shared ancestor.

When You Need to Know the Total Count

Group(subviewsOf:) collects all resolved subviews as a SubviewsCollection before you iterate. Use this when the container's layout depends on how many children are present — for example, switching to a compact style when there are more than ten items:

struct AdaptiveGrid<Content: View>: View {
    @ViewBuilder let content: Content

    var body: some View {
        Group(subviewsOf: content) { subviews in
            let compact = subviews.count > 10
            LazyVGrid(columns: compact ? [GridItem(), GridItem()] : [GridItem()]) {
                ForEach(subviews) { subview in
                    subview
                        .padding(compact ? 4 : 12)
                }
            }
        }
    }
}

Edge Cases and Gotchas

1. ContainerValues don't propagate to nested containers

This is intentional, but it bites people. If CardContainer contains another custom container inside one of its cards, the inner container cannot read values declared for the outer one. Each container only sees values applied directly to its immediate subviews.

2. A single ForEach resolves to N subviews, not one

CardContainer {
    ForEach(items) { item in  // This resolves to items.count subviews
        Text(item.title).badgeStyle(item.badge)
    }
}

ForEach(subviewOf:) inside the container will iterate each resolved child independently. This is usually what you want — but if you're counting views or computing layout from subviews.count, remember that one ForEach in the caller can produce many resolved subviews.

3. Don't use @Environment when you mean ContainerValues

The symptom: a modifier you applied to one card affects the nested views inside a completely different card. That's an environment value leaking through the tree. If the customization is meant for one container-child relationship only, it belongs in ContainerValues, not @Environment.

4. Resolved subviews are immutable — apply modifiers in the container

You cannot reach into a resolved subview and mutate it after extraction. Any styling that depends on container logic (spacing, color, scale) must be applied in the container's body, wrapping the subview:

// Correct — wrap, don't mutate
CardView(badgeStyle: style) { subview }

// Won't compile — subview is not a mutable View
subview.badgeStyle(.highlighted)  // ❌

5. @ViewBuilder is required on the content parameter

If you omit @ViewBuilder from the container's content parameter, conditionals and ForEach inside the caller break. Always declare the content closure with @ViewBuilder.

Practical Use Cases

  • Card grids — per-card visual variants (highlighted, muted, locked) without polluting @Environment
  • Custom tab bars — each tab declares its icon, label, and badge count; the tab bar reads them without the views needing to know about each other
  • Stepped forms — each step declares whether it's required, optional, or completed; the form container uses that to render a progress indicator
  • Adaptive layouts — containers that switch between list, grid, and compact modes based on item count or per-item size hints

Summary

ContainerValues solves a specific problem that @Environment handles poorly: per-child customization inside a reusable container. Declare values with @Entry, apply them with containerValue(_:_:), and read them inside ForEach(subviewOf:) or Group(subviewsOf:). The scoping guarantee — values are only visible to the immediate owning container — keeps your view hierarchy predictable and prevents the accidental side-effects that come with environment propagation.

Subscribe to Swiftloop

Sign up now to get access to the library of members-only issues.
Jamie Larson
Subscribe