SwiftUI List Bindings

Behind the Scenes

June 10, 2021 - 4 min read

Lists are probably one of the most popular UI elements in iOS apps, and we’ve come a long way since UITableViewController was first introduced. Creating lists in UIKit wasn’t exactly rocket science, but it did require some ceremony.

SwiftUI has made creating lists really easy - check out this snippet that displays a list of todos:

struct StaticTodoList: View {
  var todos: [TodoItem]
  var body: some View {
    List(todos) { item in 

With just three lines of code, we’re able to create a simple list view.

Things get slightly more complicated once we try to make the items in the list rows editable. For example, TextField requires a binding to the element we want to edit:

TextField("Todo", text: $todo.title)

However, until now, SwiftUI didn’t provide a simple way to access mutable bindings to collection items. And this is why we ended up building code like this:

struct TodoList: View {
  @Binding var todos: [TodoItem]
  var body: some View {
    List(0..<todos.count) { index in
      TextField("Todo", text: $todos[index].title)

Not only does this code look a lot more complicated than it needs to - it also forces SwiftUI to re-render the entire list even if we change just one element in the list, and this might result in slow UI updates and flickering.

As of WWDC 2021, SwiftUI supports bindings for list elements. To use this feature, all we need to do is pass a binding to the collection into the list, and SwiftUI will hand us a binding to the current element into the closure:

struct BetterTodoList: View {
  @Binding var todos: [TodoItem]
  var body: some View {
    List($todos) { $todo in
      TextField("Todo", text: $todo.title)

This code is easier to read, and in fact looks similar to the original code we started with. And the best part: you can back-deploy this code to any release of iOS that supports SwiftUI.

Behind the scenes

I am sure you’ll appreciate the increased simplicity this brings to your code. Simpler code means fewer errors, and this also much easier to read and write.

But how does it work?

Well, there are a few things that come together to make this possible. Let’s first look at what makes it possible to use bindings in List (and ForEach, by the way).

Let’s jump to the definition of the List initialiser (by using either ⌃ + ⌘ + J or ⌃ + ⌘ + Click).

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension List where SelectionValue == Never {

    /// Creates a list that computes its rows on demand from an underlying
    /// collection of identifiable data.
    /// - Parameters:
    ///   - data: A collection of identifiable data for computing the list.
    ///   - rowContent: A view builder that creates the view for a single row of
    ///     the list.
    public init<Data, RowContent>(_ data: Binding<Data>, @ViewBuilder rowContent: @escaping (Binding<Data.Element>) -> RowContent) where Content == ForEach<LazyMapSequence<Data.Indices, (Data.Index, Data.Element.ID)>, Data.Element.ID, HStack<RowContent>>, Data : MutableCollection, Data : RandomAccessCollection, RowContent : View, Data.Element : Identifiable, Data.Index : Hashable 

    /// Creates a list that identifies its rows based on a key path to the
    /// identifier of the underlying data.
    /// - Parameters:
    ///   - data: The data for populating the list.
    ///   - id: The key path to the data model's identifier.
    ///   - rowContent: A view builder that creates the view for a single row of
    ///     the list.
    public init<Data, ID, RowContent>(_ data: Binding<Data>, id: KeyPath<Data.Element, ID>, @ViewBuilder rowContent: @escaping (Binding<Data.Element>) -> RowContent) where Content == ForEach<LazyMapSequence<Data.Indices, (Data.Index, ID)>, ID, HStack<RowContent>>, Data : MutableCollection, Data : RandomAccessCollection, ID : Hashable, RowContent : View, Data.Index : Hashable

A quick look at the documentation (with API diffs turned on) shows that this is an addition from 12.5 to 13.0b1.

Let’s unwrap what this new initialiser signature tells us:

  • The first parameter (data: Binding<Data>) defines that the initialiser expects a binding that is generic over Data (e.g. an array, like in our case).
  • The second parameter (@ViewBuilder rowContent: @escaping (Binding<Data.Element>) -> RowContent) , defines the trailing closure in which we define the body of our list. This signature shows us that the closure will receive a binding that is generic over the type of the elements of the data collection

This is what allows us to pass in a collection of bindings and receive them, one by one, in the body of the List.

But it’s only part of the equation! Why can we use the $ sign syntax?

To understand this, we need to do some more API archeology (or get a hint from someone who worked on this - thanks, Holly).

SE-0293 (Extend Property Wrappers to Function and Closure Parameters) discusses extending property wrappers to function and closure parameters. This is why we are able to use Binding on the trailing closure in the first place. What makes the $ sign possible is discussed in the Closures section of the proposal:

For closures that take in a projected value, the property-wrapper attribute is not necessary if the backing property wrapper and the projected value have the same type, such as the @Binding property wrapper from SwiftUI. If Binding implemented init(projectedValue:), it could be used as a property-wrapper attribute on closure parameters without explicitly writing the attribute:

let useBinding: (Binding<Int>) -> Void = { $value in

And - sure enough - if we look at the API diff for Binding, there it is: init(projectedValue: Binding<Value>) is an addition from Xcode 12.5 to Xcode 13.0b1.

So - there you have it: we can use bindings inside of List and ForEach thanks to the work done in SE-0293, and the addition of the respective initialisers to List (see the documentation here), ForEach (see the documentation here) and Binding (see the documentation here).

As Holly mentioned here, the new $ syntax for closure parameters is a compile-time aspect of the language, and works as long as you’re compiling with Swift 5.5.


Which brings us to the question: why can this be back-deployed to earlier iOS versions? The new $ syntax for closure parameters is just one piece of the puzzle, but we also have new APIs (the additional initialisers) that need to be made available on previous versions of the OS.

How can we make new APIs available to earlier versions of the runtime?

There is an entire document on the Swift repo that discusses the topic of Library Evolution, the ‘ability to change a library without breaking source or binary compatibility’.

The document is a bit vague about @_alwaysEmitIntoClient, but it seems that annotating a function, computed property, or subscript with this attribute will result in the code being emitted into the client binary (i.e. our app), thereby making it possible to use new APIs on previous, ABI-stable versions of the Swift runtime, and it seems like this is what the team did for this specific feature.

If you want to dig deeper into this topic, the Library Evolution blog post provides more details about the underlying concepts, and when it is a good idea to enable library evolution support (and when not).


I always find it fascinating how much engineering goes into seemingly small features of the language and framework features we often take for granted. Thanks to everyone who worked on this!

As Matt mentions in What’s New in SwiftUI (at the 7:37 mark), this feature will help us build more error-free (and performant!) apps, so now is a good time to go through your code and update all your lists to use the new features.

Thanks for reading! 🔥

Read next