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 TextFields 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.