- Published on
- 6 min read Advanced
> Building Custom Container Views in SwiftUI with Subviews
// 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.
A Simple Carousel
Here's a horizontal carousel that wraps each child view in a full-width page:
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:
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:
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:
extension ContainerValues {
@Entry var isHighlighted: Bool = false
}
Create a convenience modifier:
extension View {
func highlighted(_ value: Bool = true) -> some View {
containerValue(\.isHighlighted, value)
}
}
Read it from subviews in your container:
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:
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:
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:
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:
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
// Continue_Learning
ControlWidgetButton for Control Center Widgets
Create interactive buttons for the Control Center using ControlWidgetButton and App Intents in iOS 18.
ViewThatFits for Adaptive Layouts in SwiftUI
Use ViewThatFits to automatically select the best layout variant based on available space, without writing manual size calculations.
How to Change the Background Color of a View in SwiftUI
Learn different ways to set background colors on SwiftUI views, from simple color fills to gradients and materials.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.