Alexito's World

A world of coding 💻, by Alejandro Martinez

ViewBuilder vs. AnyView

One of the major benefits of SwiftUI is its amazing DSL. A feature that not only makes it nice to read but also makes it possible to use the type system to unlock tremendous performance benefits. AnyView is a view that defies all of this, and although useful in very specific circumstances, you should always try to use a better alternative.

Let's explore how all this ties together.

SwiftUI and the Type System

One major difference of SwiftUI with other declarative UI frameworks is how it uses the power of Swift at its maximum. Swift is a statically typed language, and that opens the door to a lot of interesting techniques with real benefits.

When you create a view hierarchy in SwiftUI, it is not only encoded at runtime with a tree of instances, it's also encoded on the type system. The framework uses views with generic type parameters to define the view hierarchy.

Watch What's behind SwiftUI DSL? - Swift Function Builders to know more about SwiftUI DSL.

Having the view hierarchy encoded in the type system lets the framework do a better job at diffing. Diffing happens when the UI needs to change. A declarative UI frameworks takes the new state of the UI, compares it with the previous one, and just performs the smaller amount of changes necessary.

It's easy to imagine how having more information available can help with this. If each decision point on a hierarchy has its own type, it means that the framework only needs to do runtime checks for the ones that can change, and even in those cases having a specific type for each case makes it easier to optimise.

To illustrate this behaviour, let's define a simple view and analyse its type.

var body: some View {
    VStack {
        Text("Hello")
        Image(systemName: "square")
    }
}

To easily see the type, you can just change the return type to an Int and read the compiler error.

Cannot convert return expression of type 'VStack<TupleView<(Text, Image)>>' to return type 'Int'

You can see how the view hierarchy is encoded in the view's type using generic parameters. We have a VStack with a TupleView that has a Text and an Image. We can imagine how with this information the framework could be able to know with no runtime check that the hierarchy is not going to change, just the content of the leaf views.

That's nice, but having to provoke an error to see the type is quite annoying. We can implement a small extension to print out the type of a view.

extension View {
    func debugType() -> some View {
        print(type(of: self))
        return self
    }
}

Adding this let us see the type of the view in the console. Much better.

But let's focus again on the return type. That some View. That's Swift's opaque return types feature being tremendously useful. Without that, having a framework like SwiftUI that creates such complex types would be impossible to work with.

Watch Swift Opaque Result Types to learn more about this feature.

some View makes it so we don't have to know any of this internal view types and avoids a lot of boilerplate. But is crucial to understand that the type is fixed at compile time, and it must be one concrete type. You can't have multiple returns with different types, it must be always one type. In other words, opaque types don't add dynamism into the system. The function has a static type as always, it's just that you don't have to write it yourself.

And this is problematic on some occasions, for example, when you want to return two different views based on some condition.

func labelOrText() -> some View {
    if showLabel {
        return Label("Bye", systemImage: "circle")
    } else {
        return Text("Bye")
    }
}

This code will give you the following compiler error:

Function declares an opaque return type, but the return statements in its body do not have matching underlying types

Type erasers and AnyView

If you really want to have multiple types in a such a strongly typed system, there is always a scape hatch: type erasers. This is a technique that deserves its own post but suffices to say that they are quite common in Swift. The type eraser for SwiftUI views is called AnyView.

By wrapping a view in an AnyView we erase the underlying genuine type of the view. The compiler doesn't know anymore what was there, a Text? an Image? No way to know. That may seem bad, but it gives us the benefit of mixing different views.

func labelOrText() -> AnyView {
    if showLabel {
        return AnyView(Label("Bye", systemImage: "circle"))
    } else {
      	return AnyView(Text("Bye"))
    }
}

Although this works, it is far from ideal. By using type erasure, we're denying useful information to the framework.

So let's look at an alternative!

Function builders with conditions

Wanting to return multiple views based on a condition often required to use AnyView in the beginning of SwiftUI. Thankfully, since Swift 5.3 function builders, the Swift feature that makes the DSL possible, added support for multiple control flow statements, including if conditions.

Watch Swift Function Builders deep dive for Swift 5.3 to see all the amazing things this feature can do.

Thanks to that you can use if conditions in SwiftUI body with no problem.

var body: some View {
    if showLabel {
        Label("Bye", systemImage: "circle")
    } else {
        Text("Bye")
    }
}

But we're still using some View as the return type. So you may ask, how are we able to have multiple types? And that's the magic, there are no multiple types, there is only a single return type. SwiftUI is using function builders to use another of its internal types, to encode the hierarchy in the type system, just as we saw before.

In this case the hierarchy has a condition, so it has a special view type for that:

_ConditionalContent<Label<Text, Image>, Text>

Use it in your own methods

Being able to use if in the body is nice, but as you can see, our previous problematic example was using a separate method. That's when most people use AnyView or move the code into the main body property. But there is a better way.

To use SwiftUI DSL a method must be marked with a special annotation: @ViewBuilder. By default, a view's body property is marked with it, that's why we can use the DSL without by default.

But when we make our own function, we're outside the DSL world. That's why we were forced to write the return statements and why the power of the if didn't work. But we can solve it easily! Just add the annotation yourself and you are back into the DSL world.

@ViewBuilder
func labelOrText() -> some View {
    if showLabel {
        Label("Bye", systemImage: "circle")
    } else {
        Text("Bye")
    }
}

Conclusion

SwiftUI uses Swift's powerful type system at its fullest. some View helps us not to have to write the complicated types, but it doesn't give us dynamism. Using AnyView denies that, so it's better to avoid it if possible. Just use @ViewBuilder when you are outside a view's body and you will be ready. That said, sometimes is still needed, so don't be afraid of it. Just know the tools you have at your disposal and pick the best for the job.

If you liked this article please consider supporting me