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.

Views using this pattern: Picker, GroupBox.

@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 to Button, 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. the label), 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 the NavigationLink 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 the NavigationLink (this is one of the reasons why the smart people at objc.io have created LazyView, which work wonders in NavigationLinks).
  • the label component is the one that comes with a @ViewBuilder modifier, this is consistent with Button's definition, however the difference here is that label should be probably be considered the secondary component, with destination 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: anything public will still need to have an explicit initializer.

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all