Binding to rows of text-fields?

I'm trying to bind an array of strings into their corresponding text fields in a scrolling list. The number of rows is variable, and corresponds to the number of elements in the string array. The user can add or delete rows, as well as changing the text within each row.


The following Playground code is a simplified version of what I'm trying to achieve


import SwiftUI
import PlaygroundSupport


struct Model {
    struct Row : Identifiable {
        var textContent = ""
        let id = UUID()
    }
    var rows: [Row]
}


struct ElementCell: View {
    @Binding var row: Model.Row
    var body: some View {
        TextField("Field",text: $row.textContent)
    }
}


struct ElementList: View {
    @Binding var model: Model
    var body: some View {
        List {
            ForEach($model.rows) {
                ElementCell(row:$0)
            }
        }
    }
}


struct ContentView: View {
    @State var model = Model(rows: (1...10).map({ Model.Row(textContent:"Row \($0)") }))
    var body: some View {
        NavigationView {
            ElementList(model: $model)
        }
    }
}


PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())


The issue is that I can't seem to get the "cell" to bind correctly with its corresponding element. In the example code above, Xcode 11.1 failed to compile it with error in line 26:


Cannot invoke initializer for type 'ForEach<_, _, _>' with an argument list of type '(Binding<[Model.Row]>, @escaping (Binding<Model.Row>) -> ElementCell)'

What would be the recommended way to bind elements that are a result of ForEach into its parent model?

Replies

The root cause is that the thing you're binding to is the container type

Model
. To get (working) bindings to its contents, the $-prefixed expression must begin with that property. If you look at Apple's SwiftUI tutorial, this is one of the reasons why they lookup the index of each landmark inside their
UserData.landmarks
array—so they can use
$userData.landmarks[idx]
to generate the binding to the actual member of the array.


In your case, you realize you can't pass

model.rows
, because that'll just end up working on a copy of the real array. However, passing
$model.rows
doesn't work because that's a
Binding<[Model.Row]>
, which isn't the
RandomAccessCollection
that the
ForEach
view requires (this is the error being reported by the compiler). It can act like one thanks to its
DynamicMemberLookup
conformance, but there isn't a way to really express that as a Swift generic parameter yet.


Next, think about how SwiftUI works. The real

@State
value is living in
ContentView
. When that state value changes—and as a
struct
that includes changes to any of its contents—the
ContentView
's
body
will be re-fetched. This will also trigger calls for the bodies of each view that holds a binding to that
Model
state variable, so your
ElementList
's body will also be re-fetched. The rows won't necessarily, because they'll bind to an individual
String
inside an individual
Model.Row
inside the
Model
.


Now, with the

Model
being the thing at the root of the state tree, we need changes to the text inside each
Row
to properly affect the
@State
property inside your
ContentView
. That means that every binding must be generated from a binding that was generated from the state value itself: they need the 'parent' links back to the original state property.


That brings us back to that index argument in the first paragraph. Inside your

ForEach
builder block, you need an expression that begins by accessing
$model
—that instance knows how to create a binding to something at a key-path, and
$something.a.b.c
is actually translated into
_something[dynamicMember: \.a.b.c]
. This means that even if you iterate over the rows—i.e. with
ForEach(model.rows)
—you can't do anything with that actual
row
instance except use it to determine a means to reach inside the real collection inside
$model
. Apple's code uses a function to fetch the index of the object with a matching identifier, but here you have a shortcut since you're iterating over the collection directly: you can use
ForEach(model.rows.indices)
to invoke your view builder with an index into the row collection, and use
$model.rows[idx]
to bind to the correct instance of the row.


This gives the following working example:


struct Model {
    struct Row: Identifiable {
        var textContent = ""
        let id = UUID()
    }

    var rows: [Row]

    init<S: Sequence>(rowContents: S) where S.Element: StringProtocol {
        self.rows = rowContents.map { Row(textContent: String($0)) }
    }
}

struct ElementCell: View {
    @Binding var row: Model.Row
    var body: some View {
        TextField("Field", text: $row.textContent)
    }
}

struct ElementList: View {
    @Binding var model: Model
    var body: some View {
        List {
            ForEach(model.rows.indices) { idx in
                return ElementCell(row: self.$model.rows[idx])
            }
        }
    }
}

struct ContentView: View {
    @State var model = Model(rowContents: (1...10).map { "Row \($0)" })
    var body: some View {
        NavigationView {
            ElementList(model: $model)
        }
    }
}