SwiftUI patterns evolution: view builders
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
andoverlay
:
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
- the main view
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!