SwiftUI Custom Containers: `Group(subviews:)` and Compositional Layouts
You have built dozens of reusable views, maybe even a few custom Layout types, yet every time you try to build a
container that inspects or rearranges its children — a card stack, a sectioned grid, a custom sidebar — you hit the
same wall: SwiftUI’s @ViewBuilder gives you an opaque blob, not an iterable collection of subviews.
iOS 18 fixes this with Group(subviews:), ContainerValues, and a set of Section-aware APIs that finally let you
decompose, annotate, and rearrange child views at the container level. This post covers the full API surface, production
patterns, and the trade-offs you need to evaluate before reaching for these tools. We will not cover the Layout
protocol itself — that is handled in Grids and Lazy Layouts in SwiftUI.
Contents
- The Problem
- Decomposing Children with
Group(subviews:) - Annotating Subviews with
ContainerValues - Section-Aware Containers
- Advanced Usage
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem
Imagine you are building a movie poster carousel for a Pixar film browser. You want a container that takes an arbitrary
number of child views, lays them out in a horizontal stack, and highlights whichever poster the user taps. Before iOS
18, the only way to iterate child views was through ForEach with explicit data, which forces the caller to pass a
collection and a view builder separately. You cannot accept plain child views the way VStack does.
// Before iOS 18 -- the caller is forced to use ForEach
struct PosterCarousel<Data: RandomAccessCollection, Content: View>: View
where Data.Element: Identifiable {
let items: Data
let content: (Data.Element) -> Content
var body: some View {
ScrollView(.horizontal) {
HStack(spacing: 12) {
ForEach(items) { item in
content(item)
}
}
}
}
}
// Usage -- verbose and inflexible
PosterCarousel(items: movies) { movie in
PosterCard(movie: movie)
}
This works, but it is not composable. Callers cannot mix heterogeneous children, inject dividers, or pass static views
alongside dynamic ones. What you really want is a container that accepts a @ViewBuilder body — just like VStack —
and still gets access to each individual child.
Decomposing Children with Group(subviews:)
Group(subviews:), introduced in iOS 18 / macOS 15, takes a @ViewBuilder content closure and hands you a
SubviewsCollection — a RandomAccessCollection of Subview values representing each resolved child, including
children produced by ForEach.
Apple Docs:
Group(subviews:)— SwiftUI
Here is the same carousel rewritten to accept arbitrary children:
@available(iOS 18.0, *)
struct PosterCarousel<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
Group(subviews: content) { subviews in
HStack(spacing: 12) {
ForEach(subviews) { subview in
subview
.containerRelativeFrame(
.horizontal,
count: 3,
spacing: 12
)
}
}
.scrollTargetLayout()
}
}
.scrollTargetBehavior(.viewAligned)
}
}
Now callers can use it exactly like a built-in container:
PosterCarousel {
PosterCard(title: "Toy Story", year: 1995)
PosterCard(title: "Finding Nemo", year: 2003)
PosterCard(title: "Inside Out 2", year: 2024)
ForEach(upcomingMovies) { movie in
PosterCard(title: movie.title, year: movie.year)
}
}
The key insight is that Group(subviews:) flattens the view hierarchy for you. Static children, ForEach children, and
even nested Group children all resolve into a single flat SubviewsCollection. You iterate them uniformly without
caring how the caller assembled them.
SubviewsCollection API
SubviewsCollection conforms to RandomAccessCollection, so you get count, subscript access, slicing, and all the
standard collection algorithms. Each element is a Subview, which is a lightweight proxy you can place directly in your
view hierarchy.
Group(subviews: content) { subviews in
if subviews.isEmpty {
ContentUnavailableView(
"No Movies",
systemImage: "film.stack"
)
} else {
// Show the first child as a hero, the rest as a grid
subviews[0]
.frame(maxWidth: .infinity, minHeight: 300)
LazyVGrid(columns: [.init(.adaptive(minimum: 150))]) {
ForEach(subviews[1...]) { subview in
subview
}
}
}
}
This pattern — a hero element followed by a grid of remaining items — is trivial with SubviewsCollection but was
effectively impossible to build as a generic container before iOS 18.
Annotating Subviews with ContainerValues
Decomposing children is powerful on its own, but real containers need metadata. Think about List and its support for
swipe actions, or TabView and its tab items. The child view communicates intent to the container through per-subview
annotations.
ContainerValues is the mechanism for this. You define a custom container value, the caller sets it via a view
modifier, and the container reads it from each Subview.
Apple Docs:
ContainerValues— SwiftUI
Defining a Container Value
Use the @Entry macro (also new in iOS 18) to declare a container value with a default:
extension ContainerValues {
@Entry var isPinned: Bool = false
@Entry var posterPriority: Int = 0
}
Setting Values from the Caller
Provide a convenience modifier so callers have a clean API:
extension View {
func pinned(_ isPinned: Bool = true) -> some View {
containerValue(\.isPinned, isPinned)
}
func posterPriority(_ priority: Int) -> some View {
containerValue(\.posterPriority, priority)
}
}
Reading Values in the Container
Inside Group(subviews:), read container values from each Subview using the containerValues property:
@available(iOS 18.0, *)
struct PinnablePosterBoard<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
Group(subviews: content) { subviews in
let pinned = subviews.filter {
$0.containerValues.isPinned
}
let unpinned = subviews.filter {
!$0.containerValues.isPinned
}
VStack(alignment: .leading, spacing: 16) {
if !pinned.isEmpty {
Text("Pinned")
.font(.headline)
HStack { ForEach(pinned) { $0 } }
}
Text("All Movies")
.font(.headline)
LazyVGrid(
columns: [.init(.adaptive(minimum: 140))]
) {
ForEach(unpinned) { $0 }
}
}
}
}
}
The caller annotates children without knowing anything about the container’s implementation:
PinnablePosterBoard {
PosterCard(title: "WALL-E", year: 2008)
.pinned()
PosterCard(title: "Up", year: 2009)
.pinned()
PosterCard(title: "Coco", year: 2017)
PosterCard(title: "Soul", year: 2020)
PosterCard(title: "Luca", year: 2021)
}
This is the same declarative contract pattern that TabView uses with .tabItem and List uses with .swipeActions
— now available for your own containers.
Section-Aware Containers
SwiftUI also introduced ForEach(sectionOf:) and SectionConfiguration to let containers understand Section
boundaries. This is essential for building sectioned lists, grouped grids, or any container that needs to treat groups
of children differently.
Apple Docs:
ForEach(sectionOf:)— SwiftUI
@available(iOS 18.0, *)
struct StudioFilmography<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
ScrollView {
Group(subviews: content) { subviews in
ForEach(sectionOf: subviews) { section in
VStack(alignment: .leading, spacing: 8) {
// Render the section header
section.header
.font(.title2.bold())
.padding(.top, 12)
// Render the section content in a grid
LazyVGrid(
columns: [.init(.adaptive(minimum: 160))]
) {
ForEach(section.content) { subview in
subview
}
}
// Render the section footer
section.footer
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.padding()
}
}
}
Callers use standard Section syntax, and your container decides how to render headers, footers, and content:
StudioFilmography {
Section("Pixar Animation Studios") {
FilmCard(title: "Monsters, Inc.", year: 2001)
FilmCard(title: "The Incredibles", year: 2004)
FilmCard(title: "Ratatouille", year: 2007)
} footer: {
Text("3 films shown")
}
Section("Walt Disney Animation") {
FilmCard(title: "Frozen", year: 2013)
FilmCard(title: "Moana", year: 2016)
FilmCard(title: "Encanto", year: 2021)
} footer: {
Text("3 films shown")
}
}
The SectionConfiguration gives you .header, .footer, and .content (another SubviewsCollection), so you have
full control over how each piece renders. No more fighting List’s opinionated styling when all you want is a sectioned
grid.
Advanced Usage
Combining ContainerValues with Sections
You can use ContainerValues and sections together. For example, a container value that controls column span inside a
sectioned grid:
extension ContainerValues {
@Entry var columnSpan: Int = 1
}
extension View {
func columnSpan(_ span: Int) -> some View {
containerValue(\.columnSpan, span)
}
}
Inside the container, read the span to decide layout:
ForEach(section.content) { subview in
let span = subview.containerValues.columnSpan
subview
.gridCellColumns(span)
}
Callers mark featured items to span multiple columns:
Section("Featured") {
FilmCard(title: "Inside Out 2", year: 2024)
.columnSpan(2)
FilmCard(title: "Elemental", year: 2023)
}
Passing Data Back to Children
Container values flow from child to container (bottom-up). If you need the container to communicate back to its children
— for example, telling a child its index or whether it is the last element — use the environment or preferences
instead. Do not try to set container values from inside Group(subviews:).
Warning:
ContainerValuesare one-directional. They flow from the child to the container, never the reverse. Attempting to mutate them insideGroup(subviews:)will not compile.
Nested Custom Containers
Custom containers compose naturally. A PosterCarousel can be placed inside a StudioFilmography section, and each
container independently resolves its own subviews. SwiftUI handles the flattening at each Group(subviews:) boundary.
StudioFilmography {
Section("Toy Story Franchise") {
PosterCarousel {
PosterCard(title: "Toy Story", year: 1995)
PosterCard(title: "Toy Story 2", year: 1999)
PosterCard(title: "Toy Story 3", year: 2010)
PosterCard(title: "Toy Story 4", year: 2019)
}
}
}
In this case, StudioFilmography sees PosterCarousel as a single subview. The carousel internally decomposes its own
children. This nesting is safe and expected.
Dynamic Container Values with Enums
For richer metadata, use an enum as your container value type:
enum CardStyle {
case standard
case featured
case minimal
}
extension ContainerValues {
@Entry var cardStyle: CardStyle = .standard
}
extension View {
func cardStyle(_ style: CardStyle) -> some View {
containerValue(\.cardStyle, style)
}
}
The container can then switch on the style to apply different layouts or decorations per child:
Group(subviews: content) { subviews in
ForEach(subviews) { subview in
switch subview.containerValues.cardStyle {
case .featured:
subview
.frame(maxWidth: .infinity, minHeight: 200)
.background(
.ultraThinMaterial,
in: .rect(cornerRadius: 16)
)
case .minimal:
subview
.frame(height: 60)
case .standard:
subview
.frame(height: 120)
.background(
.regularMaterial,
in: .rect(cornerRadius: 8)
)
}
}
}
Performance Considerations
Group(subviews:) resolves all child views eagerly when the container’s body is evaluated. This is the same behavior as
VStack and HStack — there is no lazy evaluation. For small to moderate child counts (under 100 views), this is
perfectly fine.
For large collections, prefer ForEach with a data source wrapped in a LazyVStack or LazyVGrid instead of relying
on Group(subviews:) to decompose hundreds of children. The decomposition itself is lightweight, but the eager body
evaluation of every child view is not.
Tip: Profile with the SwiftUI Instruments template if you suspect subview resolution is a bottleneck. Look for long
bodyevaluation times on your container view. TheView Bodyinstrument shows exactly which views are being evaluated and how often.
Container values have negligible overhead. They are stored in a dictionary-like structure on each subview proxy and read in O(1) time. Declaring dozens of custom container values will not affect performance in any measurable way.
One consideration specific to section-aware containers: ForEach(sectionOf:) iterates all sections and their content
eagerly. If you have many sections each with many items, the initial layout pass can be expensive. Consider flattening
into a single scrollable list with sticky headers if you exceed several hundred items across all sections.
Apple Docs:
Subview— SwiftUI
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| Reusable container that rearranges or filters children | Use Group(subviews:). This is exactly what it was designed for. |
| Per-child metadata (pinned, priority, style) | Define ContainerValues. Clean and declarative. |
| Sectioned containers with headers and footers | Use ForEach(sectionOf:) and SectionConfiguration. |
| Large data sets (hundreds or thousands of items) | Prefer ForEach with a data source inside a lazy container. |
| Simple composition without child inspection | A plain @ViewBuilder body is sufficient. Skip Group(subviews:). |
| Custom layout algorithms (positioning, sizing) | Use the Layout protocol instead. |
| Passing state from container to children | Use @Environment or preferences. Container values flow upward only. |
The general rule: if you find yourself wishing @ViewBuilder gave you an array of children, Group(subviews:) is the
answer. If you do not need to touch individual children, skip it — the indirection adds complexity without benefit.
Note: These APIs require iOS 18 / macOS 15. If your deployment target is earlier, you will need to gate the container behind
@available(iOS 18.0, *)and provide a fallback. For projects that already target iOS 18+, there is no reason to avoid these APIs.
Summary
Group(subviews:)gives containers access to a flatSubviewsCollectionof resolved children, including those produced byForEachand nested groups.ContainerValueswith the@Entrymacro let children annotate themselves with metadata that the container reads — the same patternTabViewandListuse internally.ForEach(sectionOf:)andSectionConfigurationenable section-aware containers with access to headers, footers, and content subviews.- These APIs resolve eagerly, so they are best suited for moderate child counts. For large data sets, keep using
ForEachwith lazy containers. - Container values flow one direction only: from child to container. Use environment values for the reverse.
These APIs were introduced in WWDC 2024’s session Demystify SwiftUI containers — worth watching for the visual explanations of subview resolution. If you want to pair custom containers with reusable styling, head over to Custom View Modifiers and Button Styles next.