Quick tips on embracing @ViewBuilder
With the release of Xcode 12 and Swift 5.3, @resultBuilder
, at the time called @_functionBuilder
, has learned quite a few tricks:
- Support for
if let
&if case
- Support for multiple Boolean conditions in 'if' statements
switch
supportif #available
support#warning
and#error
handling- Support for
let
/var
declarations - ...probably more (please let me know if I missed anything!)
As @ViewBuilder
is SwiftUI's @resultBuilder
dedicated to building views, all these enhancements have helped immensely with views's declaration expressiveness.
Another gain is that SwiftUI's AnyView
is no longer necessary in most cases:
in this article, let's see how we can improve our codebase thanks to these advances.
This article goes hand in hand with John Sundell`s Avoiding SwiftUI’s AnyView, I suggest to read John's article first.
The road to @ViewBuilder
As an example, let's take a function returning a sheet, named presentSheet
:
enum SettingsSheet: Identifiable {
case languagePicker
case securityPin
case signIn
var id: Int { hashValue }
}
struct ContentView: View {
@State private var showingSheet: SettingsSheet?
var body: some View {
content
.sheet(item: $showingSheet, content: presentSheet)
}
var content: some View {
// ...
}
func presentSheet(_ sheet: SettingsSheet) -> some View {
// ...
}
}
When presentSheet(_:)
is called, we need to return a view for the given SettingsSheet
.
The most direct thing that we'd do is probably use a switch
statement and return a different view for each case:
func presentSheet(_ sheet: SettingsSheet) -> some View {
switch sheet {
case .languagePicker:
return LanguagePicker()
case .securityPin:
return SecurityPinView()
case .signIn:
return SignInScreen()
}
}
However this is not possible and won't build, as the function promises to return some View
, a.k.a. the same opaque type in each case of the switch
, but we're returning different views instead.
Prior to Xcode 12 (and the new @resultBuilder
enhancements) we had mainly two solutions, the most straightforward was to wrap each returned view with AnyView
:
func presentSheet(_ sheet: SettingsSheet) -> some View {
switch sheet {
case .languagePicker:
return AnyView(LanguagePicker())
case .securityPin:
return AnyView(SecurityPinView())
case .signIn:
return AnyView(SignInScreen())
}
}
This works, as we're type-erasing everything and returning AnyView
in all possible paths (keeping the same opaque type promise).
Another solution, that would avoid using AnyView
, was to attach @ViewBuilder
to the function, and replace the switch statement with a long list of if-else
statements:
@ViewBuilder
func presentSheet(_ sheet: SettingsSheet) -> some View {
if sheet == .languagePicker {
LanguagePicker()
} else if sheet == .securityPin {
SecurityPinView()
} else if sheet == .signIn {
SignInScreen()
}
}
This works because simple single boolean conditions were supported by @ViewBuilder
before Swift 5.3, however this solution is not going to work when our enum also has associated types:
due to this limitation, most projects stuck with the AnyView
solution instead.
Speaking of @ViewBuilder
and moving to Xcode 12 and Swift 5.3, things got better as we can now go back to the our original attempt, remove the return
statements, and things will work right away:
@ViewBuilder
func presentSheet(_ sheet: SettingsSheet) -> some View {
switch sheet {
case .languagePicker:
LanguagePicker()
case .securityPin:
SecurityPinView()
case .signIn:
SignInScreen()
}
}
This is much better and would work even when our enum has associated types.
Side effects
Let's imagine that we need to add some side effects into the function, like pushing an analytics event:
@ViewBuilder
func presentSheet(_ sheet: SettingsSheet) -> some View {
Analytics.log(.presenting(sheet)) // <-- side effect
switch sheet {
case .languagePicker:
LanguagePicker()
case .securityPin:
SecurityPinView()
case .signIn:
SignInScreen()
}
}
This won't work, as @ViewBuilder
doesn't know how to handle the return type of our analytics log (likely Void
).
The preferred solution is to not have this kind of side effects in the view at all, however sometimes we will face such challenges, the analytics one is just an example.
In this case we can overcome the challenge by separating the side effect logic from the view presentation, and return the view presentation result in presentSheet(_:)
:
func presentSheet(_ sheet: SettingsSheet) -> some View {
Analytics.log(.presenting(sheet)) // <-- side effect
return _presentSheet(sheet)
}
// 👇🏻 Our original implementation
@ViewBuilder
func _presentSheet(_ sheet: SettingsSheet) -> some View {
switch sheet {
case .languagePicker:
LanguagePicker()
case .securityPin:
SecurityPinView()
case .signIn:
SignInScreen()
}
}
The new presentSheet(_:)
function still returns what our original presentSheet(_:)
returned, but now we can add any amount of arbitrary logic beside it.
What if the side effect is in the middle of our switch statement?
@ViewBuilder
func presentSheet(_ sheet: SettingsSheet) -> some View {
switch sheet {
case .languagePicker:
LanguagePicker()
case .securityPin:
doSomething() // <-- side effect
SecurityPinView()
case .signIn:
SignInScreen()
}
}
Once again we can separate this logic by splitting the presentSheet(_:)
presentation from the side effects, similarly to how we did above.
In this case we might need to refactor the side effects logic into a new function (with a new switch statement) and call that from the presentSheet(_:)
.
However when these side effects have actually something to do with the view presentation, we might not wish to separate the logic, in such scenarios we can use a small trick by remembering that:
@resultBuilder
has gained support forlet
andvar
declarations- Swift functions are first-class citizens
...which is a fancy way to say that we can declare a new variable with the result of our side effect, and just not use it:
@ViewBuilder
func presentSheet(_ sheet: SettingsSheet) -> some View {
switch sheet {
case .languagePicker:
LanguagePicker()
case .securityPin:
let _ = doSomething() // <-- side effect
SecurityPinView()
case .signIn:
SignInScreen()
}
}
This is completely legal swift code and will work as expected.
Is AnyView still needed?
We've seen how thanks to the latest advancements in @resultBuilder
we can get rid of most workarounds we needed prior to Xcode 12, however there are still some scenarios where AnyView
is necessary:
- when using iOS availability with a pre-iOS 14 condition, as
buildLimitedAvailability(_:)
is iOS 14+ only:
var body: some View {
if #available(iOS 13.4, *) {
return AnyView(
... // A view for iOS 13.4+
)
} else {
return AnyView(
... // A different view for iOS 13.0 - 13.3
)
}
}
- when type erasure is necessary: we've covered an example in Custom SwiftUI view styles
- ...and probably more: if you're aware of any other situation, please let me know!
Conclusions
SwiftUI has completely revolutionized how we declare UI in our apps, with Xcode 12 we've made big steps forward for even more elegant expressiveness, and I'm sure this trend will continue this year with new API allowing us to do more, with less code.
Thank you for reading and stay tuned for more articles!