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:

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 for let and var 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:

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
    )
  }
}

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!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all

Explore Swift

Browse all