SwiftUI framework provides us with various kinds of views to use when implementing user interfaces. Some of them have a particular significance, as they act as containers for other SwiftUI views. Such containers are, for instance, the HStack, VStack, ForEach, LazyVGrid, LazyHGrid, and of course, a lot more. We use them all the time to build our views, but did you know that we can also create our own container views as well?
In this post you are going to find out everything you need to know in order to create your own container views. It would be interesting to say though, before going any further, that doing so is possible thanks to the ViewBuilder attribute; a special keyword which, according to the documentation, it allows us to create SwiftUI views from closures. I prompt you to read more on Apple docs here.
Note: You can find an older tutorial about how to use ViewBuilder attribute to create and return SwiftUI views from methods.
So, let’s explore everything demonstrating a simple scenario.
Defining the goal
For the sake of the demonstration, let’s suppose that the following SwiftUI view is a custom container; it’s going to display other SwiftUI views:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
struct FadingContainer: View { @State private var opacity: Double = 0 var body: some View { ZStack { RoundedRectangle(cornerRadius: 8) .fill(Color.indigo) // Content from other SwiftUI views will be shown here… } .opacity(opacity) .frame(height: 350) .padding() .onAppear { withAnimation(.linear(duration: 0.4)) { opacity = 1 } } } } |
As you can find out in the above code snippet, the view is presented initially transparent and becomes fully opaque using an animation effect with a specific duration. It’s given a particular height, and in a ZStack
there is a rounded rectangle filled with some color. On top of that, it’s going to display other views.
In addition, let’s consider the following simple views that provide some dummy content:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
struct FirstContentView: View { var body: some View { VStack { Text(“Hello!”) .font(.largeTitle) Text(“This is sample content #1”) .font(.title) } } } struct SecondContentView: View { var body: some View { VStack { Text(“Welcome!”) .font(.headline) Text(“This is sample content #2”) .font(.title2) } } } |
Finally, let’s also see the following, last one, view. It contains two buttons, and depending on the one that will be tapped, the proper content is given to the FadingContainer
instance:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
struct MainView: View { enum ContentType { case none, first, second } @State private var contentType: ContentType = .none var body: some View { ZStack { VStack { Button(“First Sample Content”) { contentType = .first } Button(“Second Sample Content”) { contentType = .second }.padding(.top, 20) } if contentType != .none { FadingContainer { if contentType == .first { FirstContentView() } else { SecondContentView() } } } } } } |
Whether the FadingContainer
will be shown or not is up to the value of the contentType
state property. By default is set to none
and FadingContainer
does not appear. When, however, gets any other value, the view redraws its content and also shows the FadingContainer
along with the proper content specified in the closure.
But this is never going to work the way the above sample views are right now! What I’m showing here is actually the goal and the topic of this post; to make a custom view accept other views as arguments through a closure:
1 2 3 4 5 6 7 8 9 |
FadingContainer { if contentType == .first { FirstContentView() } else { SecondContentView() } } |
Let’s get down to making everything work.
The necessary additions to the custom container view
Right before going any further, it would be useful to clarify a detail that has probably become obvious already; the FadingContainer
view will not be getting a SwiftUI view as argument directly, rather a closure that will return a SwiftUI view.
In order to make it capable of accepting such an argument, it’s necessary to declare a matching stored property in it first, so we can keep the input closure. Obviously, the type of that property should be the same to the provided argument; a closure returning a SwiftUI view as a value:
1 2 3 |
@State private var content: () -> View |
The above, however, is not a valid declaration. View
is a protocol and not a concrete type such as a class or struct, so we cannot use it as so.
The solution here lies on turning FadingContainer
into a generic type exactly like that:
1 2 3 4 5 |
struct FadingContainer<Content>: View where Content: View { … } |
Alternatively, we could also write this:
1 2 3 4 5 |
struct FadingContainer<Content: View>: View { … } |
The Content
generic type now refers to any SwiftUI view, and contrarily to the previous restriction, we are allowed to use this one just like every other concrete type. With that in mind, let’s properly declare the property that will store the provided closure returning any SwiftUI view in the FadingContainer
struct:
1 2 3 4 5 6 7 |
struct FadingContainer<Content>: View where Content: View { @State private var content: () -> Content … } |
That’s not all yet, though.
It’s also necessary to explicitly define an initializer method with one parameter value. That parameter value must be a closure just like the one declared above, but most importantly, we must prepend it with the @ViewBuilder
attribute:
1 2 3 4 5 6 7 8 9 10 |
struct FadingContainer<Content>: View where Content: View { @State private var content: () -> Content init(@ViewBuilder content: @escaping () -> Content) { self.content = content } … } |
In the initializer’s body we assign the content
parameter value to the content
property declared in FadingContainer
.
At this point FadingContainer
can successfully accept and store any SwiftUI view as argument through the closure upon initialization. What it doesn’t still do though, is to show that view.
To change that, all we have to do is to replace the line that contains the following comment:
1 2 3 |
// Content from other SwiftUI views will be shown here… |
with a call to the closure that returns the SwiftUI view:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
struct FadingContainer<Content>: View where Content: View { … var body: some View { ZStack { RoundedRectangle(cornerRadius: 8) .fill(Color.indigo) // Call content() to show the input SwiftUI content. content() } // View modifiers… } } |
After having made all the above additions and changes, the following lines back in the MainView
are going to work just fine, leading to the appearance of the one or the other view in the FadingContainer
:
1 2 3 4 5 6 7 8 9 |
FadingContainer { if contentType == .first { FirstContentView() } else { SecondContentView() } } |
Providing other arguments to the container view
Along with a closure that returns a SwiftUI view, a custom container view can also accept any number of other arguments as well. To demonstrate that, let’s consider the following stored state property that keeps the animation duration regarding the view’s appearance:
1 2 3 4 5 6 7 |
struct FadingContainer<Content>: View where Content: View { private var animationDuration: Double … } |
This new property will get its value upon initialization, so we have to update the initializer and add a respective parameter. To avoid complexities when using FadingContainer
, that new parameter will be the first in the list of parameters:
1 2 3 4 5 6 7 8 9 10 11 12 |
struct FadingContainer<Content>: View where Content: View { private var animationDuration: Double init(animationDuration: Double, @ViewBuilder content: @escaping () -> Content) { self.animationDuration = animationDuration self.content = content } … } |
Of course, we should not forget to update the hardcoded animation duration value with the animationDuration
we just defined:
1 2 3 4 5 |
withAnimation(.linear(duration: animationDuration)) { opacity = 1 } |
With all that in place, we can now initialize FadingContainer
providing both the desired animation duration and a closure with the view we want to show:
1 2 3 4 5 6 7 8 9 |
FadingContainer(animationDuration: 0.5) { if contentType == .first { FirstContentView() } else { SecondContentView() } } |
Providing other closures as arguments
Besides simple arguments, we can also supply a custom container view with additional arguments that are closures.
To see an example of that in the FadingContainer
, let’s suppose that we want to add a dismiss button. On tap, it will be calling a closure. Back in the call site, we’ll trigger the disappearance of the FadingContainer
view in the closure’s body.
Let’s take things step by step, starting with the declaration of the following (last one) stored property in the FadingContainer
view. Along with that, let’s update the initializer method as well so the view can actually receive a closure matching to the dismiss action when it gets initialized:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
struct FadingContainer<Content>: View where Content: View { private var onDismiss: () -> Void init(animationDuration: Double, @ViewBuilder content: @escaping () -> Content, onDismiss: @escaping () -> Void) { self.animationDuration = animationDuration self.content = content self.onDismiss = onDismiss } … } |
Notice that the onDismiss
parameter value is assigned to the local state property with the same name.
In the view’s body now let’s make the following small addition; a button that calls the onDismiss
closure when it gets tapped:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
var body: some View { ZStack { RoundedRectangle(cornerRadius: 8) .fill(Color.indigo) content() // Add a button at the bottom of any content shown in the view. VStack { Spacer() Button(“Dismiss”) { onDismiss() } .padding(.bottom) .foregroundColor(.black) } } // View modifiers… } |
Back to the call site, which in this case is the MainView
demonstrated previously, we can now initialize the FadingContainer
view passing three arguments; the animation duration time, the closure with any arbitrary SwiftUI content, and the closure to handle the dismiss action:
1 2 3 4 5 6 7 8 9 10 11 |
FadingContainer(animationDuration: 0.5) { if contentType == .first { FirstContentView() } else { SecondContentView() } } onDismiss: { contentType = .none } |
By setting none
to contentType
the FadingContainer
view will not be displayed any more. Even though all these present a quite particular example, they clearly demonstrate how to provide more closures as arguments to any SwiftUI view that acts as a custom container view.
Conclusion
As you have found out by reading up until this point, it’s not difficult to create custom containers in SwiftUI and provide views as arguments to other views. All the magic happens with the ViewBuilder attribute in the initializer method and by making the container view generic, so it can declare closures having as return type any SwiftUI view. In addition to all that, you have also met how to provide other arguments as well, no matter if they are plain values or closures. Undeniably, the final result is similar to built-in SwiftUI container views. So, don’t hesitate to create your own container views, especially when it’s about to reuse them.
Thank you for reading! ????