A Tale of Two Custom Container APIs
How the Public Custom Container Solution Stacks Up Against It's Shadowy, Private Counterpart in SwiftUI
Container Views are the steel beams of SwiftUI. They provide the structure that holds all of its contents up, as well as for everything that gets built around it. Whether List, Stack, Group… all of these are core to declaratively composing experiences.
The concept of being able to actually create a custom container has been tossed around since SwiftUI was released. However, you could only go so far until you, most likely, needed to just rely on one of the given containers.
That is, seemingly, until WWDC24's session called Demystify SwiftUI Containers, where they introduced new API's (ForEach(subviews:) and Group(subviews:)) to finally gain access to the finer points of a container. All seemed exciting and well in the SwiftUI universe.
I decided to give this API a whirl and, at first, I was amazed at how simple and powerful it was to spin up a container in no time. I then decided to take it a step further and add some basic interactions. And that's when things took a turn...
Ease at the Cost of Performance?
As easy as these “container constructors” are, it was heartbreaking to hit a tremendous catch: I couldn’t make them reactive. At least, not in a performant manner.
Let me explain.
I experimented with Apple’s own sample from Demystify SwiftUI Containers. When you download the sample code, there’s a v2 (just uses the ForEach) and a v5 (fully featured with Group and Sections). With both, I converted one of the sets of data into a State variable and created a tapGesture to add an item to that State.
My expectation was to see the new item appear, and I did.
However… I also expected that, when monitoring with the SwiftUI Instrument, there would be evidence that only a new item was being drawn.
What I Tried (And The Sad Results)
I tested extensively. Meaning, I tried:
Trying as-is
Adding
.id(...)
to each subviewWrapping each subview in
EquatableView
Creating a custom
ContainerValue
to create unique id’s within theForEach
None of these techniques reduced the redraw count. SwiftUI still diffed the subviews
collection seemingly from scratch every time (meaning that despite any identifiable or equatable help I gave it, it was as if they didn’t exist). This resulted in a ton of draws per tap. Worse, with every tap, it consistently grew by 2 draws. This meant we were experiencing a linear growth in performance loss.
I really wanted to see better results. I wanted to believe there were better results. And so I tried another experiment. One that involved breaking into one of the worst kept secrets in the SwiftUI API.
Worst Kept Secret: VariadicView
One of the coolest discoveries in SwiftUI was its secret API. Chris Eidhof shares a bit about this as a footer in one of his articles where he quickly shares how we can go "spelunking into the .swiftinterface
".
In the same article, we’re introduced to VariadicView
. Before the ForEach
and Group
options, THIS was how custom containers were being built at the expert level.
And so, I had a thought: what if the worst kept secret was the best solution?
Testing the Theory
To test the theory, I took a trusted example of VariadicView from a trusted source: Jacob from Jacob’s Tech Tavern. He wrote a great article on VariadicView that you should pause right now and go read (and then come right back). It seemed only right that I pull his repo as a base, attempt creating a converted version of his example, and then compare their performance using Instruments.
Implementation Comparison
What I did was pinpoint his Variadic implementation:
struct ChatList<Content: View>: View {
var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
_VariadicView.Tree(ChatLayout()) {
content
}
}
}
struct ChatLayout: _VariadicView_MultiViewRoot {
func body(children: _VariadicView.Children) -> some View {
List {
ForEach(children) { child in
child
.inverted()
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.inverted()
}
}
Then, I created what should represent the ForEach
approach:
struct ChatContainer<Content: View>: View {
var content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
List {
ForEach(subviews: content) { subview in
subview
.inverted()
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.inverted()
}
}
Comparing the code, it eliminates a few lines of code, making for a tighter implementation. It would have been great if the results matched.
However, when using ForEach
(again, trying all the various ways), not only was it less performant (42 draws on tap as opposed to only 13 with Variadic)… the UX was negatively affected. Using Variadic, a new message would appear with a bit of an animation. Now, no animation.
Resolution is the Problem?
In the WWDC session on custom layouts, Apple clearly distinguishes between Declared Subviews and Resolved Subviews. The newer APIs, including subviews in layout and container contexts, work with the resolved version — that is, a system-generated representation after the SwiftUI engine computes the view hierarchy.
But here’s the catch: by calling them “resolved,” Apple hints at something deeper — any change to the underlying set of views requires a re-resolution. That includes adding or removing just a single item. And when resolution resets, the identity of each subview is essentially re-evaluated, leading to widespread redraws, even if most views are unchanged.
Key Takeaway
When building custom containers in SwiftUI,
ForEach(subviews:)
may look like the right tool — but it doesn’t diff how you would expect. This leads to massive redraws as children are added or removed. Surprisingly, VariadicView is the only API I found that eliminates those redraws and scales cleanly with dynamic content.
Spread the Word: VariadicView is still the Custom (Dynamic) Container Solution
This article was to be a victory lap for Custom Containers when added to my Trello board of topics. And, to be fair, it is a win if you are creating static container in your experience.
The fact of the matter is that there is a fundamental limitation to ForEach(subviews:)
that is not immediately clear in the docs. Therefore, if you anticipate any interactive/dynamic aspects of your container, then perhaps turn to the secret ways of the shadows... er... VariadicView
.
VariadicView
might not be the headline API in this story, but it’s still the hero behind the scenes.