Search code examples
arraysswiftswiftuiswift3

Delete a Binding from a list in SwiftUI


I am trying to simply delete an element from a list in Swift and SwiftUI. Without binding something in the ForEach loop, it does get removed. However, with binding something it crashes with an error Index out of range. It seems like the ForEach loop is constant, not updating, and trying to render at the specific index.

Example view code:

@ObservedObject var todoViewModel: TodoViewModel
//...
ForEach(self.todoViewModel.todos.indices) { index in
    TextField("Test", text: self.$todoViewModel.todos[index].title)
        .contextMenu(ContextMenu(menuItems: {
            VStack {
                Button(action: {
                    self.todoViewModel.deleteAt(index)
                }, label: {
                    Label("Delete", systemImage: "trash")
                })
            }
        }))                                    
}

Example view model code:

final class TodoViewModel: ObservableObject {
    @Published var todos: [Todo] = []
    
    func deleteAt(_ index: Int) -> Void {
        self.todos.remove(at: index)
    }
}

Example model code:

struct Todo: Identifiable {
    var id: Int
    var title: String = ""
}

Does anyone know how to properly delete an element from a list where it is bound in a ForEach loop?


Solution

  • This happens because you're enumerating by indices and referencing binding by index inside ForEach

    I suggest you switching to ForEachIndexed: this wrapper will pass both index and a correct binding to your block:

    struct ForEachIndexed<Data: MutableCollection&RandomAccessCollection, RowContent: View, ID: Hashable>: View, DynamicViewContent where Data.Index : Hashable
    {
        var data: [(Data.Index, Data.Element)] {
            forEach.data
        }
        
        let forEach: ForEach<[(Data.Index, Data.Element)], ID, RowContent>
        
        init(_ data: Binding<Data>,
             @ViewBuilder rowContent: @escaping (Data.Index, Binding<Data.Element>) -> RowContent
        ) where Data.Element: Identifiable, Data.Element.ID == ID {
            forEach = ForEach(
                Array(zip(data.wrappedValue.indices, data.wrappedValue)),
                id: \.1.id
            ) { i, _ in
                rowContent(i, Binding(get: { data.wrappedValue[i] }, set: { data.wrappedValue[i] = $0 }))
            }
        }
        
        init(_ data: Binding<Data>,
             id: KeyPath<Data.Element, ID>,
             @ViewBuilder rowContent: @escaping (Data.Index, Binding<Data.Element>) -> RowContent
        ) {
            forEach = ForEach(
                Array(zip(data.wrappedValue.indices, data.wrappedValue)),
                id: (\.1 as KeyPath<(Data.Index, Data.Element), Data.Element>).appending(path: id)
            ) { i, _ in
                rowContent(i, Binding(get: { data.wrappedValue[i] }, set: { data.wrappedValue[i] = $0 }))
            }
        }
        
        var body: some View {
            forEach
        }
    }
    

    Usage:

    ForEachIndexed($todoViewModel.todos) { index, todoBinding in
        TextField("Test", text: todoBinding.title)
            .contextMenu(ContextMenu(menuItems: {
                VStack {
                    Button(action: {
                        self.todoViewModel.deleteAt(index)
                    }, label: {
                        Label("Delete", systemImage: "trash")
                    })
                }
            }))
    }