Creating custom SwiftUI container views
Discover page available: SwiftUIAlthough SwiftUI ships with a quite large number of built-in container views, such as VStack
, HStack
and List
, sometimes we might also want to define our own custom containers as well.
For example, let’s say that we’re working on an app that features a carousel-like component, which lets our users scroll through a horizontal list of items. That component is currently implemented like this:
struct Carousel<Content: View>: View {
var content: () -> Content
var body: some View {
ScrollView(.horizontal) {
HStack(content: content).padding()
}
}
}
That’s a good start, but our current implementation does have a quite major limitation compared to SwiftUI’s built-in containers — we’re currently only able to pass a single Content
view to it.
That might work fine as long as we’re using something like ForEach
(as that’d give us a single return value within our carousel’s content
closure), but if we tried to do the following, then we’d get a compiler error:
struct OnboardingCarousel: View {
var body: some View {
Carousel {
WelcomeCard()
GettingStartedCard()
ExploreCard()
}
}
}
That’s a bit of a shame, since the above approach would be a very natural way of declaring a Carousel
instance, as it exactly mimics the way that built-in containers like HStack
and VStack
are used.
Although we could address the above problem by wrapping all of our subviews within a Group
(which would again give us a single return value within our closure), having to do that at every call site would be quite inconvenient. Thankfully, though, there’s a better way — and that’s to annotate our content
closure with SwiftUI’s ViewBuilder
attribute, like this:
struct Carousel<Content: View>: View {
var content: () -> Content
init(@ViewBuilder content: @escaping () -> Content) {
self.content = content
}
var body: some View {
ScrollView(.horizontal) {
HStack(content: content).padding()
}
}
}
Adding the ViewBuilder
attribute to a closure makes it possible to use the full power of SwiftUI’s DSL within it, meaning that we’ll now be able to define our OnboardingCarousel
exactly the way that we originally intended — really nice!
However, if our code base includes multiple kinds of container views, then always having to repeat the above initializer declaration can become a bit repetitive, especially since the required syntax is quite complex.
So let’s see if we can improve things with a little bit of protocol-oriented programming. If we assume that each of our container views is going to use the same pattern as the above Carousel
view (in that it has a generic Content
type, and accepts a content
closure), then we could model those capabilities using the following protocol:
protocol ContainerView: View {
associatedtype Content
init(content: @escaping () -> Content)
}
We make our new protocol extend SwiftUI’s View
protocol to inherit all of its requirements. To learn more about that technique, check out “Specializing protocols in Swift”.
Next, let’s extend our new ContainerView
protocol with a convenience initializer that adds the ViewBuilder
attribute that we previously had to add manually — like this:
extension ContainerView {
init(@ViewBuilder _ content: @escaping () -> Content) {
self.init(content: content)
}
}
Note how we add an underscore in front of our convenience initializer’s parameter label. That’s to avoid ending up in an infinite loop in case a conforming type doesn’t actually declare our required initializer, since now those two initializers will have different signatures.
If we now make all of our custom container views conform to ContainerView
, rather than using View
directly, then we’ll simply be able to declare our content
closure like we originally did, while still gaining full ViewBuilder
capabilities as well:
struct Carousel<Content: View>: ContainerView {
var content: () -> Content
var body: some View {
ScrollView(.horizontal) {
HStack(content: content).padding()
}
}
}
Our OnboardingCarousel
now works exactly like it did before, only now we’ve made it much easier to define both Carousel
and any other container views that we might want to build either now or in the future.
Worth noting, though, is that this particular implementation only supports container views that don’t accept any additional parameters, although we could always add support for that if that’s something that we’ll end up needing.