Operating on a Binding to an Array of items in SwiftUI


I have found that it is not uncommon in SwiftUI to end up with a Binding<Array<Item>> datatype. Handing this in a nice way to enable seamless editing of the data is not always that straightforward.


In this post I will share my solution for handling array of strings to produce a list of text fields that dynamically adds an additional field so that users can use to add new items to the list and support swipe to delete.


Firstly let us consider a 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 does not include add additional rows so the user is unable to add any new items.


Adding Swipe to delete


Both the List and Form views in SwiftUI support a simple operator that enables swipe to delete actions. This is the .delete modifier that can be placed on the ForEach view.


     var body: some View {
        Form {
            ForEach(0..<items.count, id: \.self) { index in
                TextField(
                    "Field \(index)",
                    text: $items[index]
                )
            }.onDelete(perform: self.delete)
        }
    }

    func delete(_ indexSet: IndexSet) {
        items.remove(atOffsets: indexSet)
    }


Notice the new delete method we have defined that uses the remove(atOffsets:) method on arrays to remove multiple items at once.


Adding a empty new item row


To make this list be 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 contents 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 loos 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 loop 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 appended a new row to our items list and then the ForEach will then add an additional empty row for the next item the user wants to add.