Dynamic List of items in SwiftUI
I have found that it is not uncommon in SwiftUI to end up with a Binding<Array<Item>>
datatype. Handling this in a nice way to enable seamless editing of the data is not always that straight forward.
In this post I will share my solution for handling an array of strings to produce a list of text fields. You can dynamically add additional text fields and support swipe to delete.
Firstly, let us consider our content view:
struct ContentView: View {
@Binding var items: [String]
var body: some View {
Form {
ForEach(0..<items.count, id: \.self) { index in
TextField(
"Field \(index)",
text: $items[index]
)
}
}
}
}
This content view will render a list of TextField
s with each row bound to the corresponding item in the items
array. But it does not include adding additional rows, so the user is unable to add any new items for now.
# Adding swipe to delete
Both List
and Form
views in SwiftUI
support a simple operator that enables swipe to delete actions. This is onDelete()
modifier that can be placed on ForEach
view.
var body: some View {
Form {
ForEach(0..<items.count, id: \.self) { index in
TextField(
"Field \(index)",
text: $items[index]
)
}
.onDelete(perform: delete)
}
}
func delete(_ indexSet: IndexSet) {
items.remove(atOffsets: indexSet)
}
Notice the new delete()
method we have defined that uses remove(atOffsets:)
method on arrays to remove multiple items at once.
# Adding an empty new item row
To make this list fully editable I would like to add an empty row to the bottom of the list so that when the user starts to type the content of this row is dynamically added to our items
array. It is important that the view hierarchy does not change while the user is typing otherwise the user might lose keyboard focus.
To do this I will add an extension to Binding
so that when indexing a row that is out of range, rather than crashing the application, a default value is returned.
extension Binding where
Value: MutableCollection,
Value: RangeReplaceableCollection
{
subscript(
_ index: Value.Index,
default defaultValue: Value.Element
) -> Binding<Value.Element> {
Binding<Value.Element> {
guard index < self.wrappedValue.endIndex else {
return defaultValue
}
return self.wrappedValue[index]
} set: { newValue in
// It is possible that the index we are updating
// is beyond the end of our array so we first
// need to append items to the array to ensure
// we are within range.
while index >= self.wrappedValue.endIndex {
self.wrappedValue.append(defaultValue)
}
self.wrappedValue[index] = newValue
}
}
}
Using this extension within our view body is simple:
var body: some View {
Form {
ForEach(0..<items.count + 1, id: \.self) { index in
TextField(
"Field \(index)",
text: $items[index, default: ""]
)
}
.onDelete(perform: self.delete)
}
}
This ForEach
adds an extra iteration that goes past the end of our items
list. As soon as a user starts to type the Binding
returned by the above extension will append a new row to our items
list and ForEach
will then add an additional empty row for the next item the user wants to add.