Articles, podcasts and news about Swift development, by John Sundell.

Creating custom SwiftUI container views

Published on 12 Nov 2020
Discover page available: SwiftUI

Although 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.