SwiftUI patterns: passing & accepting views
When building advanced screens, at some point our view definitions will need to accept external views as parameters for maximum flexibility.
This is something SwiftUI solves beautifully via composition, which we covered in Composing SwiftUI views.
In this article let's explore how SwiftUI itself use this pattern, and what kind of variants there are.
Explicit types
When a view wants to offer some flexibility but still expect a certain view instance, this can be achieved by explicitly asking for that instance type.
Some views use Text
as labels and for accessibility, in such cases it doesn't make sense to accept any view, as VoiceOver needs to read text out loud:
// Definition
extension Image {
public init(_ name: String, bundle: Bundle? = nil, label: Text)
}
// Use
struct ContentView: View {
var body: some View {
Image(
"wwdc20-10040-header",
label: Text("Session title: Data Essentials in SwiftUI")
)
}
}
Kind reminder to use the
init(decorative:bundle:)
Image
initializer when an image should be ignored for accessibility purposes.
A view might also offer some extra features only when specific views are passed, for example new in iOS 14 we can inline images within Text
:
// Definition
extension Text {
@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *)
public init(_ image: Image)
}
// Use
struct ContentView: View {
var body: some View {
Text("Five")
+
Text(Image(systemName: "star.circle.fill"))
+
Text("Stars")
}
}
Generic views
Similarly to above, when a view expects just one "simple" generic view, this is achieved by asking for a generic view instance:
// Definition
struct Picker<Label: View, SelectionValue: Hashable, Content: View>: View {
init(
selection: Binding<SelectionValue>,
label: Label, // <- here
@ViewBuilder content: () -> Content
)
}
// Use
struct ContentView: View {
enum PizzaTopping: String, CaseIterable, Identifiable {
case 🍍, 🍄, 🫒, 🐓
var id: String { rawValue }
}
@State var flavor: PizzaTopping = .🍍
var body: some View {
NavigationView {
Form {
Picker(
selection: $flavor,
// 👇🏻 Our simple view.
label: Label("Pick your topping:", systemImage: "plus.circle")
) {
ForEach(PizzaTopping.allCases) { topping in
Text(topping.rawValue.capitalized)
.tag(topping)
}
}
}
}
}
}
Since Picker
accepts a generic label
instance, nothing stops us to pass a ScrollView
, or a Button
, etc, or even a combination of all of them together: SwiftUI will try its best to make it work (and it does!).
However, when the view asks for a view instance, this is probably meant to be a single simple view.
@ViewBuilder
This is by far the most popular way to pass views in SwiftUI. @ViewBuilder
is SwiftUI's @resultBuilder
, enabling us to construct views from closures:
when a view accepts a @ViewBuilder
parameter, in most cases said parameter will be a core part of the new view.
The most generic definition of Button
uses this pattern, where the button label
can be anything we desire:
// Definition
struct Button<Label> : View where Label : View {
init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)
}
// Use
struct ContentView: View {
var body: some View {
Button(action: { print("button tapped") }) {
... // Anything goes!
}
}
}
All SwiftUI stacks use this approach as well, where our content will be distributed in a different axis based on the stack used:
// Definition
struct VStack<Content> : View where Content : View {
init(
alignment: HorizontalAlignment = .center,
spacing: CGFloat? = nil,
@ViewBuilder content: () -> Content
)
}
// Use
struct ContentView: View {
var body: some View {
VStack {
... // Anything goes!
}
}
}
Views using this pattern:
ColorPicker
,CommandGroup
,CommandMenu
,DatePicker
,Form
,Group
,HStack
,LazyHStack
,LazyVStack
,Link
,List
,NavigationView
,Picker
,ProgressView
,ScrollView
,Section
,Slider
,Stepper
,TabView
,Toggle
,VStack
,ZStack
.
Double @ViewBuilder
Some views sport separate generic components, where each can be as complicated as needed. In such scenarios the view will ask for a separate @ViewBuilders
parameter for each component:
// Definition
struct Label<Title, Icon> : View where Title : View, Icon : View {
init(@ViewBuilder title: () -> Title, @ViewBuilder icon: () -> Icon)
}
// Use
struct ContentView: View {
var body: some View {
Label(
title: { VStack { ... } },
icon: { ScrollView { ... } }
)
}
}
Views using this pattern: Label, Menu, ProgressView.
@escaping @Viewbuilder
We have a particular @Viewbuilder
use case when the closure is marked as @escaping
, meaning that it won't be used right during the view initialization.
Most of these cases will also pass a parameter to the closure, allowing us to change the view with this parameter.
// Definition
extension ForEach where Content: View {
public init(
_ data: Data,
id: KeyPath<Data.Element, ID>,
@ViewBuilder content: @escaping (Data.Element) -> Content
)
}
// Use
struct ContentView: View {
var body: some View {
HStack {
ForEach((0...9), id: \.self) { number in
Text("\(number)")
}
}
}
}
ForEach
, similarly to List
, shows how this closure can be called multiple times when building the view body, therefore our closure will (potentially) be used to add multiple views to the final view.
Another interesting use case of @escaping
is in SwiftUI readers, namely GeometryReader and ScrollViewReader, where our closure is called every time a redraw is needed, giving us access to runtime information that is not available at build time:
// Definition
struct GeometryReader<Content: View>: View {
@inlinable init(@ViewBuilder content: @escaping (GeometryProxy) -> Content)
}
// Use
struct ContentView: View {
var body: some View {
GeometryReader { proxy in
Text(
"The parent proposed size is \(proxy.size.width)x\(proxy.size.height)"
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
Views using this pattern: ForEach, List, GeometryReader, ScrollViewReader, OutlineGroup.
One-off exceptions
There are a couple of views that have their own pattern not used by any other view, they both are interesting so let's highlight them here.
@ViewBuilder & @escaping @ViewBuilder
DisclosureGroup
, which we built from scratch in Adding optional @Bindings to SwiftUI views, is the only view that accepts both a non-escaping @ViewBuilder
and an escaping one.
This is to make the view as light as possible, and only generate the content when (and if) it's shown/disclosed:
// Definition
struct DisclosureGroup<Label: View, Content: View> : View {
init(
// 👇🏻 This closure will run only when/if the user taps on the disclosure group.
@ViewBuilder content: @escaping () -> Content,
@ViewBuilder label: () -> Label
)
}
// Use
struct ContentView: View {
var body: some View {
List {
DisclosureGroup {
// This closure will run only when/if the user taps on the disclosure group.
Text("Lazy-loaded content")
} label: {
Text("Tap to show content")
}
}
}
}
List
uses the same approach for its hierarchy variants, which we covered and built from scratch in SwiftUI Hierarchy List:
in List
's case there's only one @escaping @ViewBuilder
parameter, as the "label" is also generated with that same parameter (from an item in the level above in the hierarchy).
Generic view + @ViewBuilder
The last exception is NavigationLink
which, funnily enough, contradicts everything we've seen above. Let's look at its simplest definition:
// Definition
struct NavigationLink<Label: View, Destination: View>: View {
init(destination: Destination, @ViewBuilder label: () -> Label)
}
NavigationLink
asks for two parameters:
- a
destination
generic view, which is the view that will be shown when the link is triggered - a
@ViewBuilder label
, which, similar toButton
, is used to actually compose the link
This view contradicts everything above for three main reasons:
- the
destination
, which probably is the most complicated component (vs. thelabel
), is just a generic view instead of a@ViewBuilder
: this can be probably seen as an invitation from the SwiftUI team to define this view separately, and not having the actual implementation in theNavigationLink
definition
- the
destination
should not be used until the link is triggered, yet we need to pass a fully initialized instance as a parameter in theNavigationLink
(this is one of the reasons why the smart people at objc.io have createdLazyView
, which work wonders inNavigationLink
s).
- the
label
component is the one that comes with a@ViewBuilder
modifier, this is consistent withButton
's definition, however the difference here is thatlabel
should be probably be considered the secondary component, withdestination
being the most important one.
Once again NavigationLink
reveals itself as an exception among all SwiftUI definitions:
as we covered in The future of SwiftUI navigation (?), I don't think it's long for this world.
Main takeaways
In this article we’ve explored how all SwiftUI native components accept an external view, here are some of the most important takeaways:
- If a view needs a specific type instance, ask for that type directly
- If a view needs a secondary, simple view instance (mainly used as a
label
), ask for a generic instance. - If a view asks for a core part of the final view, which can be as complex as needed, use
@ViewBuilder
. - If a view needs to build multiple parts of the final view via closures, maybe not even all at once, use
@escaping @Viewbuilder
- it's totally fine to pass parameters to the closure when/if needed
Conclusions
Accepting external views is probably one of the most powerful patterns that we can use to make our apps and design truly composable:
SwiftUI itself uses this pattern a lot, sometime it's even hidden via convenience API, so developers don't even need to be aware of it happening.
Do you use composition in your apps? Where do you find it working (or not) well? Please let me know!
Thank you for reading and stay tuned for more articles!
Bonus track: Swift 5.4
Credits to Matt Young for the tip!
New in Swift 5.4 we have @resultBuilder
support for stored properties, which makes our internal views more convenient to write, take the following view for example:
struct CardView<Content: View>: View {
// 👇🏻 New in Swift 5.4
@ViewBuilder let content: Content
var body: some View {
content
}
}
Thanks to this definition Swift now synthesizes the following initializer:
internal init(@ViewBuilder content: () -> Content) {
self.content = content()
}
The only way to obtain the same initializer before Swift 5.4 was to explicitly declare it ourselves:
// Before Swift 5.4
struct CardView<Content: View>: View {
// 👇🏻 We can't add @ViewBuilder in stored properties before Swift 5.4
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
var body: some View {
content
}
}
As the rest of Swift synthesization, this generates an
internal
initializer: anythingpublic
will still need to have an explicit initializer.