BS
BleepingSwift
Published on
6 min read
Advanced

> Building Custom Container Views in SwiftUI with Subviews

Share:

// What_You_Will_Learn

  • Use ForEach(subviews:) and Group(subviews:) to iterate resolved subviews
  • Create custom container values with the @Entry macro
  • Build reusable container components like card stacks and carousels
  • Understand the difference between declared and resolved subviews

Before iOS 18, building a container view that could inspect and style its children individually required relying on the private _VariadicView API. At WWDC 2024, Apple shipped public replacements that make this pattern a first-class citizen. The session Demystify SwiftUI containers covers the full API surface, and it opens up some powerful layout possibilities.

The Building Blocks

Two new initializers do the heavy lifting:

ForEach(subviews:content:) iterates over resolved subviews one at a time, handing each to your closure as a Subview value. Think of it like ForEach over data, but for views.

Group(subviews:transform:) gives you the entire SubviewsCollection at once. Since it conforms to RandomAccessCollection, you can use subscripts, slicing, count, and all the usual collection APIs.

Declared vs. Resolved Subviews

This is an important concept. When you write views in a @ViewBuilder closure, those are "declared subviews." SwiftUI resolves them into the actual views that appear on screen. A ForEach over 5 items is one declared subview but produces 5 resolved subviews. An EmptyView resolves to zero. The new container APIs work with the resolved views, not what you wrote in code.

Here's a horizontal carousel that wraps each child view in a full-width page:

Swift
import SwiftUI

struct Carousel<Content: View>: View {
    @ViewBuilder var content: Content

    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack(spacing: 16) {
                ForEach(subviews: content) { subview in
                    subview
                        .containerRelativeFrame(.horizontal)
                        .clipShape(.rect(cornerRadius: 12))
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
    }
}

You'd use it like any other container:

Swift
Carousel {
    Image("photo1")
        .resizable()
        .scaledToFill()
    Image("photo2")
        .resizable()
        .scaledToFill()
    Image("photo3")
        .resizable()
        .scaledToFill()
}

Each image becomes a paging card in the carousel. The ForEach(subviews:) call resolves the three images and applies the frame and corner radius to each one individually.

Index-Based Layouts with Group

When you need to treat the first child differently from the rest, Group(subviews:) is the right tool because it gives you the whole collection:

Swift
struct MagazineLayout<Content: View>: View {
    @ViewBuilder var content: Content

    var body: some View {
        Group(subviews: content) { subviews in
            if subviews.isEmpty {
                ContentUnavailableView(
                    "No Content",
                    systemImage: "doc"
                )
            } else {
                VStack(spacing: 16) {
                    // First item gets hero treatment
                    subviews[0]
                        .frame(maxWidth: .infinity)
                        .frame(height: 240)
                        .clipShape(.rect(cornerRadius: 16))

                    // Remaining items in a 2-column grid
                    if subviews.count > 1 {
                        LazyVGrid(
                            columns: [
                                GridItem(.flexible()),
                                GridItem(.flexible()),
                            ],
                            spacing: 12
                        ) {
                            ForEach(subviews[1...], id: \.id) { subview in
                                subview
                                    .frame(height: 160)
                                    .clipShape(.rect(cornerRadius: 8))
                            }
                        }
                    }
                }
            }
        }
    }
}

The .isEmpty check lets you handle the empty state gracefully, and subscript access makes it easy to pull out specific items.

Custom Container Values

Container values let child views pass data up to their immediate container. Unlike environment values (which flow down) or preference keys (which aggregate up the tree), container values are scoped to the direct parent-child relationship.

Define a container value with the @Entry macro:

Swift
extension ContainerValues {
    @Entry var isHighlighted: Bool = false
}

Create a convenience modifier:

Swift
extension View {
    func highlighted(_ value: Bool = true) -> some View {
        containerValue(\.isHighlighted, value)
    }
}

Read it from subviews in your container:

Swift
struct CardStack<Content: View>: View {
    @ViewBuilder var content: Content

    var body: some View {
        VStack(spacing: 12) {
            ForEach(subviews: content) { subview in
                let isHighlighted = subview.containerValues.isHighlighted

                subview
                    .padding(isHighlighted ? 20 : 16)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .background(
                        isHighlighted
                            ? Color.blue.opacity(0.1)
                            : Color(.secondarySystemBackground),
                        in: .rect(cornerRadius: 12)
                    )
                    .overlay(
                        RoundedRectangle(cornerRadius: 12)
                            .strokeBorder(
                                isHighlighted
                                    ? Color.blue.opacity(0.3)
                                    : Color.clear,
                                lineWidth: 1
                            )
                    )
            }
        }
    }
}

At the call site, any child can mark itself:

Swift
CardStack {
    Text("Regular item")
    Text("Featured item")
        .highlighted()
    Text("Another regular item")
}

The highlighted item gets different padding, background, and a border, all driven by the container value.

Working with Sections

Containers can also decompose content into sections, which is useful for building things like grouped lists or settings screens:

Swift
struct GroupedCards<Content: View>: View {
    @ViewBuilder var content: Content

    var body: some View {
        VStack(spacing: 24) {
            ForEach(sections: content) { section in
                VStack(alignment: .leading, spacing: 8) {
                    if !section.header.isEmpty {
                        section.header
                            .font(.caption)
                            .foregroundStyle(.secondary)
                            .textCase(.uppercase)
                    }

                    VStack(spacing: 1) {
                        ForEach(section.content) { subview in
                            subview
                                .padding()
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .background(Color(.secondarySystemBackground))
                        }
                    }
                    .clipShape(.rect(cornerRadius: 10))
                }
            }
        }
    }
}

Use it with standard Section views:

Swift
GroupedCards {
    Section("Account") {
        Label("Profile", systemImage: "person")
        Label("Notifications", systemImage: "bell")
    }
    Section("Support") {
        Label("Help Center", systemImage: "questionmark.circle")
        Label("Feedback", systemImage: "envelope")
    }
}

Putting it All Together

Here's a more complete example that combines container values and conditional styling into a flexible card container:

Swift
import SwiftUI

enum CardVariant {
    case standard
    case featured
    case compact
}

extension ContainerValues {
    @Entry var cardVariant: CardVariant = .standard
}

extension View {
    func cardVariant(_ variant: CardVariant) -> some View {
        containerValue(\.cardVariant, variant)
    }
}

struct AdaptiveCardList<Content: View>: View {
    @ViewBuilder var content: Content

    var body: some View {
        Group(subviews: content) { subviews in
            let columns = subviews.count > 4
                ? [GridItem(.flexible()), GridItem(.flexible())]
                : [GridItem(.flexible())]

            LazyVGrid(columns: columns, spacing: 12) {
                ForEach(subviews) { subview in
                    let variant = subview.containerValues.cardVariant
                    cardView(for: subview, variant: variant)
                }
            }
        }
    }

    @ViewBuilder
    private func cardView(for subview: Subview, variant: CardVariant) -> some View {
        switch variant {
        case .featured:
            subview
                .padding(20)
                .background(
                    Color.blue.opacity(0.08),
                    in: .rect(cornerRadius: 16)
                )
                .overlay(
                    RoundedRectangle(cornerRadius: 16)
                        .strokeBorder(Color.blue.opacity(0.2))
                )
        case .compact:
            subview
                .padding(8)
                .background(
                    Color(.tertiarySystemBackground),
                    in: .rect(cornerRadius: 8)
                )
        case .standard:
            subview
                .padding(16)
                .background(
                    Color(.secondarySystemBackground),
                    in: .rect(cornerRadius: 12)
                )
        }
    }
}

This container adapts its grid column count based on how many children it has, and each child can control its own styling through the cardVariant container value.

These APIs require iOS 18+, macOS 15+, watchOS 11+, tvOS 18+, and visionOS 2.0+. For the full API reference, check the Subview documentation and ContainerValues documentation.

// Frequently_Asked

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.