SwiftUI patterns evolution: view builders

SwiftUI
29 June 2021

With WWDC21, SwiftUI has officially entered its third iteration. Big changes also bring pattern shifts: let's see how SwiftUI itself has further embraced view builders.

Generic views

In SwiftUI patterns: passing & accepting views we've explored how SwiftUI accepts views to embed within other views.
One of the main ways was accepting a generic view, used for example in Section's header and footer parameters:

extension Section where Parent: View, Content: View, Footer: View {

  /// Creates a section with a header, footer, and the provided section content.
  /// - Parameters:
  ///   - header: A view to use as the section's header.
  ///   - footer: A view to use as the section's footer.
  ///   - content: The section's content.
  public init(header: Parent, footer: Footer, @ViewBuilder content: () -> Content)
}

To be used as:

Section(
  header: Text("This is a header"), 
  footer: Text("This is a footer")
) {
  // section content
}

The original idea of this API was probably to indicate that Section expects a single simple view for those parameters. However, this limitation was easily bypassed, for example by using a Group or another view:

Section(
  header: Group {
    Text("This is not a simple header")
    ForEach(1..<100) { _ in
      Text("Five")
    }
  }, 
  footer: Group {
    Text("This is not a simple footer")
    ForEach(1..<100) { _ in
      Text("Stars")
    }
  }
) {
  // section content
}

// or 

Section(
  header: VeryComplicatedViewAsHeader(), 
  footer: VeryComplicatedViewAsFooter()
) {
  // section content
}

Moreover, accepting a generic view made things awkward in case we wanted to "just" add a condition:

Section(
  header: Group {
    if shouldShowHeader {
      Text("This is a header")
    }
  },
  footer: Text("This is a footer")
) {
  // section content
}

New this year, all APIs accepting a generic view parameter have been deprecated in favor of new alternatives using view builders:

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
extension Section where Parent: View, Content: View, Footer: View {

  /// Creates a section with a header, footer, and the provided section
  /// content.
  ///
  /// - Parameters:
  ///   - content: The section's content.
  ///   - header: A view to use as the section's header.
  ///   - footer: A view to use as the section's footer.
  public init(
    @ViewBuilder content: () -> Content, 
    @ViewBuilder header: () -> Parent, 
    @ViewBuilder footer: () -> Footer
  )
}

Which make all previous workarounds obsolete:

Section {
  // section content
} header: {
  if shouldShowHeader {
    Text("this is a header")
  }
} footer: {
  Text("this is a footer")
}

Views affected:

  • Section
extension Section where Parent: View, Content: View, Footer: View {
  // From:
  public init(
    header: Parent, // 👈🏻 1
    footer: Footer, // 👈🏻 2
    @ViewBuilder content: () -> Content
  )

  // To:
  public init(
    @ViewBuilder content: () -> Content, 
    @ViewBuilder header: () -> Parent, // 👈🏻 1
    @ViewBuilder footer: () -> Footer // 👈🏻 2
  )
}
  • Picker:
extension Picker {
  // From:
  public init(
    selection: Binding<SelectionValue>, 
    label: Label, // 👈🏻
    @ViewBuilder content: () -> Content
  )

  // To:
  public init(
    selection: Binding<SelectionValue>, 
    @ViewBuilder content: () -> Content, 
    @ViewBuilder label: () -> Label // 👈🏻
  )
}
  • Slider:
extension Slider {
  // From:
  public init<V>(
    value: Binding<V>, 
    in bounds: ClosedRange<V> = 0...1, 
    onEditingChanged: @escaping (Bool) -> Void = { _ in }, 
    minimumValueLabel: ValueLabel, // 👈🏻 1
    maximumValueLabel: ValueLabel, // 👈🏻 2
    @ViewBuilder label: () -> Label
  ) where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint

  // To:
  public init<V>(
    value: Binding<V>, 
    in bounds: ClosedRange<V> = 0...1, 
    @ViewBuilder label: () -> Label, 
    @ViewBuilder minimumValueLabel: () -> ValueLabel, // 👈🏻 1
    @ViewBuilder maximumValueLabel: () -> ValueLabel, // 👈🏻 2
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  ) where V: BinaryFloatingPoint, V.Stride: BinaryFloatingPoint
}

Interestingly, Stepper already used the view builders approach from the start. It clearly was ahead of its time!

  • GroupBox:
extension GroupBox {
  // From:
  public init(
    label: Label, // 👈🏻
    @ViewBuilder content: () -> Content
  )

  // To:
  public init(
    @ViewBuilder content: () -> Content, 
    @ViewBuilder label: () -> Label // 👈🏻
  )
}
  • NavigationLink:
extension NavigationLink {
  // From:
  public init(
    destination: Destination, // 👈🏻
    @ViewBuilder label: () -> Label
  )

  // To:
  public init(
    @ViewBuilder destination: () -> Destination, // 👈🏻
    @ViewBuilder label: () -> Label
  )

Unfortunately, this was the only change made to NavigationLink (FB8722348, FB8910787, FB8997675, FB9197698).

View Modifiers

This shift is not limited to just views. Modifiers have been updated as well:

  • background and overlay:
extension View {
  // From:
  @inlinable public func background<Background: View>(
    _ background: Background, // 👈🏻
    alignment: Alignment = .center
  ) -> some View

  // To:
  @inlinable public func background<V: View>(
    alignment: Alignment = .center, 
    @ViewBuilder content: () -> V // 👈🏻
  ) -> some View
}

extension View {
  // From:
  @inlinable public func overlay<Overlay: View>(
    _ overlay: Overlay, // 👈🏻
    alignment: Alignment = .center
  ) -> some View

  // To:
  @inlinable public func overlay<V: View>(
    alignment: Alignment = .center, 
    @ViewBuilder content: () -> V // 👈🏻
  ) -> some View
}
  • mask:
extension View {
  From:
  @inlinable public func mask<Mask: View>(
    _ mask: Mask // 👈🏻
  ) -> some View

  To:
  @inlinable public func mask<Mask: View>(
    alignment: Alignment = .center, // 🆕
    @ViewBuilder _ mask: () -> Mask // 👈🏻
  ) -> some View
}
  • contextMenu:
extension View {
  // From:
  public func contextMenu<MenuItems: View>(
    _ contextMenu: ContextMenu<MenuItems>? // 👈🏻
  ) -> some View

  // To:
  public func contextMenu<MenuItems: View>(
    @ViewBuilder menuItems: () -> MenuItems // 👈🏻
  ) -> some View

The ContextMenu view has been deprecated altogether.

Parameters order

Careful readers might have noticed already, but the parameters upgrade to view builders was not the only change in the definitions above. Most new declarations also present a different parameter order.

To understand why, let's have a look at Picker as an example. As a reminder, here are the old/new APIs:

extension Picker {
  // From:
  public init(
    selection: Binding<SelectionValue>, 
    label: Label, // 👈🏻
    @ViewBuilder content: () -> Content
  )

  // To:
  public init(
    selection: Binding<SelectionValue>, 
    @ViewBuilder content: () -> Content, 
    @ViewBuilder label: () -> Label // 👈🏻
  )
}

And here's a Picker defined with the legacy API:

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,
          label: Text("Pick your topping")
        ) {
          ForEach(PizzaTopping.allCases) { topping in
            Text(topping.rawValue)
              .tag(topping)
          }
        }
      }
    }
  }
}

Let's focus on the Picker declaration:

Picker(
  selection: $flavor,
  label: Text("Pick your topping")
) {
  ForEach(PizzaTopping.allCases) { topping in
    Text(topping.rawValue)
      .tag(topping)
  }
}

If the SwiftUI team only changed the label parameter type to a view builder without reordering the parameters, we would have the following declaration:

Picker(selection: $flavor) {
  Text("Pick your topping")
} content: {
  ForEach(PizzaTopping.allCases) { topping in
    Text(topping.rawValue)
      .tag(topping)
  }
}

We have two trailing closures defining two views: it's clear what the second closure is, labeled content, but what about Text("Pick your topping")?

It's easy to answer now, but it won't be in a few months, or for somebody else reviewing our code today: is that a prompt? a suggestion? an accessibility description?

Let's now use the actual new initializer definition:

Picker(selection: $flavor) {
  ForEach(PizzaTopping.allCases) { topping in
    Text(topping.rawValue)
      .tag(topping)
  }
} label: {
  Text("Pick your topping")
}

This is much clearer. The first closure contains the main definition for Picker, while the second closure defines the view label, as we can tell from the closure name.

On a similar note, view modifiers have also changed order, in this case to gain the trailing closure syntax.
Let's take background as an example:

extension View {
  // From:
  @inlinable public func background<Background: View>(
    _ background: Background, // 👈🏻
    alignment: Alignment = .center
  ) -> some View

  // To:
  @inlinable public func background<V: View>(
    alignment: Alignment = .center, 
    @ViewBuilder content: () -> V // 👈🏻
  ) -> some View
} 

Since the content parameter has shifted to the last parameter, we can take advantage of the trailing closure syntax:

mainView
  .background(alignment: .bottom) {
    backgroundView
  }

Which is much better than if the view parameter stayed in its original position:

mainView
  .background(
    content: {
      backgroundView
    },
    alignment: .bottom
  )

Backport to early OSes

Unfortunately, the new APIs are supported only in upcoming OSes (iOS 15+, macOS 12+, tvOS 15+, watchOS 8+).

However, besides the initializer definition, the views and modifiers behavior is unchanged:
we can make our own extensions, mocking the new API, and making them compatible all the way to iOS 13 (or equivalent for other platforms).

The following is a backport example for Section, we can use the same approach with all other views/modifiers:

@available(iOS, introduced: 13.0, deprecated: 15.0, 
           message: "Delete this extension, as it's no longer necessary in iOS 15+")
@available(macOS, introduced: 10.15, deprecated: 12.0, 
           message: "Delete this extension, as it's no longer necessary in macOS 12+")
@available(tvOS, introduced: 13.0, deprecated: 15.0, 
           message: "Delete this extension, as it's no longer necessary in tvOS 15+")
@available(watchOS, introduced: 6.0, deprecated: 8.0, 
           message: "Delete this extension, as it's no longer necessary in watchOS 8+")
extension Section where Parent: View, Content: View, Footer: View {
  /// Creates a section with a header, footer, and the provided section
  /// content.
  ///
  /// - Parameters:
  ///   - content: The section's content.
  ///   - header: A view to use as the section's header.
  ///   - footer: A view to use as the section's footer.
  public init(
    @ViewBuilder content: () -> Content,
    @ViewBuilder header: () -> Parent,
    @ViewBuilder footer: () -> Footer
  ) {
    self.init(header: header(), footer: footer(), content: content)
  }
}

Because this extension matches 1-1 the new APIs, once we drop support for older OSes, we can simply delete this extension, and Xcode will automatically start using SwiftUI's new initializer. No further change necessary.

Since we wrote the extension, we can start using the new pattern right away, even before Xcode 13 is officially released.

Main takeaways

  • Accepting views via a generic view is a deprecated pattern in SwiftUI, accept view builders instead
  • Reorder your closure parameters for the best API use experience:
    • the main view @ViewBuilder parameter comes first, labels and secondary view closures come later
    • enable trailing closures syntax when possible
    • if your view accepts @escaping closures to be called on associated events, these come after the view builders

Conclusions

When Swift came out, every early major version brought big disrupting changes.
The SwiftUI team has clearly learned from those painful experiences: a SwiftUI project built in 2019 compiles fine today in Xcode 13.

While SwiftUI APIs have been backward-compatible, this doesn't mean that the framework hasn't changed, quite the contrary! SwiftUI has and continues to evolve in this third iteration. Let's see where SwiftUI brings us next!

What other new patterns have you noticed in Xcode 13? Let me know via email or Twitter!

This article is part of a series exploring new SwiftUI features. We will cover many more during the rest of the summer: subscribe to Five Stars's feed RSS or follow @FiveStarsBlog on Twitter to never miss new content!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all