blog

Don't use escaping closures in SwiftUI

Rens Breur, May 1, 2022

SwiftUI stacks, container views such as ScrollView and many controls such as Button all use @ViewBuider to allow us to specify what other views they should display. They give depth to our views, and allow us to create new views defined as a hierarchy of other views.

We can also use @ViewBuilder ourselves. As a simple example, consider a view that allows a user to show or hide its contents:

struct Collapsable<Content: View>: View {
  @State var collapsed = false

  init(@ViewBuilder content: () -> Content) {
    // ...
  }

  var body: some View {
    VStack {
      if !collapsed {
        // content...
      }
      Button(collapsed ? "↓ open" : "↑ close") {
        collapsed.toggle()
      }
    }
  }
}

This view could be used like this:

var body: some View {
  // ...
  Collapsable {
    ExtraOptions()
  }
}

When writing the Collapsable view, we are faced with a choice. Either we can evaluate the closure that returns the view immediately and store the content in the view, or we make the closure escaping, store the closure and only evaluate it when we need to in the body:

// Approach 1
struct Collapsable<Content: View>: View {
  let content: Content

  init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  // ...
}

// Approach 2
struct Collapsable<Content: View>: View {
  let content: () -> Content

  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }

  // ...
}

I've seen that developers tend to use the approach 2, and feel that this is more efficient. It may look more efficient; it looks like they only create the content when needed. However, if anything, this approach is less efficient, and it can even have unwanted side effects. In the rest of this post, we will explore why.

SwiftUI views as functions

We think about closures as functions, but SwiftUI views conceptually are also functions. The inputs are the variables in the view, some of them are defined as constants and set in the initializer, some of them are managed by SwiftUI and annotated by property wrappers such as @State. The output of a SwiftUI view, seen as a function, is the body. Our SwiftUI views don't do any work on their own, all state is managed by SwiftUI and the only time it needs our views, is when one of the inputs changes and it needs to know which output view corresponds to those inputs.

If we compare the runtime memory representation of closures and SwiftUI views, they are very similar. SwiftUI views in runtime are just a collection of the constants and variables they are initialized with, and a reference to their body. (In fact if we are using a view directly, we don't even need a reference to body, but call it directly.) Closures in runtime are a collection of the parameters that they are capturing, and a reference to the function that is created from the code block.

SwiftUI uses structs instead of closures so that we can define variables that are managed externally by SwiftUI. But another reason SwiftUI uses structs instead of closures for views, is that it can now perform heavy optimizations to only evaluate the view bodies as few times as it has to. In the context of functions, many of these optimizations have names. For example, SwiftUI can decide not to evaluate the body of a view if its variables haven't changed. If a function is not evaluated when the parameters haven't changed, this is called memoization.

Lazy without closures

To see why the analogy of views and functions is helpful, and to see why approach 1 is not bad for performance, it is interesting to look at NavigationLink and the way we can define its destination.

NavigationLink("Details", destination: DetailsView())

This means the DetailsView is already created the moment our NavigationLink is displayed, isn't that inefficient? The reason it is not, is because the destination view is a view, and so already a function. Its output, the body, won't be evaluated until it is displayed. SwiftUI views are very lightweight.

You might think that this changes when we use a more complex DetailView, that uses a view model that starts a network request when it is initialized, but it shouldn't. ObservableObjects come from outside, and if the view uses a model object accessed using the @StateObject property wrapper, this model object is not created until the view is displayed either. This is one of the reasons a StateObject is initialized with an autoclosure.

Just like the destination of a NavigationLinkis not evaluated until it is displayed, the contents of Collapsable in approach 1 are not evaluated until they are displayed. In both approaches, we need to store the variables that the content uses, and know which body to compute for those variables. Storing a closure does not save us anything.

Does the world exist if we're not looking?

In approach 2 of our Collapsable implementation, we use a closure to store our content and evaluate this in the body. Due to the optimizations that SwiftUI uses for views, the indirection that approach 2 introduces produces has an unwanted and interesting side effect.

We will first check the behavior of a Collapsable view written using approach 1. This is how the Collapsable implementation looks like, I chose that it is initially collapsed:

struct Collapsable<Content: View>: View {
  @State var collapsed: Bool = true
  let content: Content

  init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  var body: some View {
    VStack {
      if !collapsed {
        content
      }
      Button(collapsed ? "↓ open" : "↑ close") {
        visible.toggle()
      }
    }
  }
}

Now let us use the view like this, where we a counter value can be collapsed and expanded, and increased by a button:

struct ContentView: View {
  @State var value: Int = 0

  var body: some View {
    VStack {
      Collapsable {
        Text("Value: \(value.value)")
      }
      Button("Increase") { value.increase() }
    }
  }
}

Everything works as expected, tapping the increase button will update the state, and the counter value can be hidden and displayed.

We will now use the Collapsable view with the same counter example, but change its implementation to follow approach 2:

struct Collapsable<Content: View>: View {
  @State var collapsed: Bool = true
  let content: () -> Content

  init(@ViewBuilder content: @escaping () -> Content) {
    self.content = content
  }

  var body: some View {
    VStack {
      if !collapsed {
        content()
      }
      Button(collapsed ? "↓ open" : "↑ close") {
        visible.toggle()
      }
    }
  }
}

With this new implementation, we experience a glitch. It generally works fine, but if we increase the counter before the Collapsable view is expanded for the first time, and only then expand it, we see a wrong value of 0. If we increase the state one more time, it jumps from 0 to the current counter value.

The underlying reason for this bug is another cool SwiftUI optimization: If a @State variable is not used in the evaluation of a body, changing this variable does not trigger a view evaluation, and does not trigger an update to the view's properties to there values as managed inside the SwiftUI framework. It is a smart optimization, and works well together with memoization: if an input variable is not used, the view does not need to be re-evaluated when it changes, and when it is used, the view only needs to be re-ealuated when it changed.

But in this specific case, it does not work correctly. When we evaluate the body for the first time and the text is collapsed, the state variable is never used, so it seems that it is not needed when evaluating the body. But when we expand the text, the state is read all of a sudden, even though the ContentView itself is not re-evaluated, and the @State is not properly prepared.

There are ways to fix this bug while keeping our content as a closure, but we are now fighting with SwiftUI optimizations instead of making use of them.

Comparing closures

If we check how the body of Collapsable is evaluated, we see a second way in which approach 2 prevents memoization. We can check this by adding a print statement to the body of Collapsable (although view invalidation is internal SwiftUI behavior that behave in very unexpected ways):

var body: some View {
  let _ = print("Evaluating body")
  // ...
}

Now let us add an unrelated state variable to the ContentView, and a button that displays this state and allows us to change it:

struct ContentView: View {
  @State var value: Int = 0
  @State var unrelated: Int = 0

  var body: some View {
    VStack {
      Collapsable {
        Text(value: "\(value)")
      }
      Button("Increase") { value += 1 }
      Button("Unrelated state (\(unrelated))") { unrelated += 1 }
    }
  }
}

This will allow us to force ContentView to be re-evaluated.

If we use the Collapsable implementation of approach 1, only tapping the "Increase" button prints "Evaluating body". But with the implementation of approach 2, we see that even the second button causes the body of Collapsable to be re-evaluated. Clearly memoization broke, but why?

In this case the reason is quite simple. In order for SwiftUI to be able to do this, it needs to compare the inputs. But closures are not Equatable, or comparable in any other way. There is no way for SwiftUI to check if this closure changed since the last time the body was evaluated.

This brings us to the end of this post. Yes, closures can be used. There are cases where you need to store a closure. Views like ForEach take a parameter, and have to store a closure. But otherwise, don't do it. Storing a closure that returns a view instead of a view is not an optimization. Views are functions, and they are very lightweight. In addition, they allow SwiftUI to perform its optimizations most efficiently. Write views that take closures in the initializer to make the syntax nicer, or to allow view builders, but evaluate them inside the initializer and store the views.