skip to Main Content

I’m developing a SwiftUI app where I display a list of items, each containing a TextField. Since editing the textfield in one of these items causes all item views to re-render, I opt’d to implement equatable on the item views to improve performance.

However, after deleting an item from the list, the TextField bindings seem to get confused. Specifically, when I edit a TextField after a deletion, the text updates in multiple fields simultaneously. This can also cause the following error if it tries to update a textfield that no longer exists: Swift/ContiguousArrayBuffer.swift:675: Fatal error: Index out of range

Steps to reproduce the issue:

  1. Run the code provided below.
  2. Delete the item labeled "text 2" by tapping the "Delete" button next to it.
  3. Tap on the "text 3" TextField and start typing.
  4. Notice that both "text 3" and "text 4" TextFields update with the same text.
  5. Now delete "text 3"
  6. Now start typing in "text 4"
  7. Notice the app crashes with an index out of range error
struct MyDataItem: Identifiable, Equatable {
    var id = UUID().uuidString
    var text: String = ""
    var placeholder: String = ""
    
    static func == (lhs: MyDataItem, rhs: MyDataItem) -> Bool {
        return lhs.id == rhs.id &&
        lhs.text == rhs.text &&
        lhs.placeholder == rhs.placeholder
    }
}

struct BugPrepro: View {
    
    @State var data: [MyDataItem] = [
        MyDataItem(placeholder: "text 1"),
        MyDataItem(placeholder: "text 2"),
        MyDataItem(placeholder: "text 3"),
        MyDataItem(placeholder: "text 4")
    ]
    
    var body: some View {
        VStack {
            ForEach($data, id: .id) { $dataItem in
                LineItemView(dataItem: $dataItem, onDelete: {
                    data.removeAll { $0.id == dataItem.id }
                })
            }
        }
    }
}

struct LineItemView: View, Equatable {
    
    @Binding var dataItem: MyDataItem
    var onDelete: () -> Void
    
    var body: some View {
        HStack {
            TextField(dataItem.placeholder, text: $dataItem.text)
            Button(action: onDelete) {
                Text("Delete")
                    .foregroundColor(.red)
            }
        }
        .padding()
        .border(.black)
    }
    
    static func == (lhs: LineItemView, rhs: LineItemView) -> Bool {
        return lhs.dataItem == rhs.dataItem
    }
}

My understanding:

  1. MyDataItem conforms to Identifiable, so SwiftUI should track each item’s identity.
  2. By implementing Equatable on LineItemView, I’m trying to prevent unnecessary view updates to improve performance.
  3. Despite this, deleting an item seems to cause the TextField bindings to overlap.

My question:

Why are the TextField bindings getting mixed up after deleting an item when using Equatable on the view? How can I prevent the views from unnecessarily re-rendering without causing this issue?

2

Answers


  1. Oh, wow, at first, I thought it was the old Objective-C issue where you couldn’t delete anything in a forEach loop, but it seems to be related to some internal SwiftUI optimization. I have played with it, and you can workaround the issue by moving @State var data to @StateObject

    import SwiftUI
    
    struct MyDataItem: Identifiable, Equatable {
        var id = UUID().uuidString
        var text: String = ""
        var placeholder: String = ""
    
        static func == (lhs: MyDataItem, rhs: MyDataItem) -> Bool {
            return lhs.id == rhs.id &&
            lhs.text == rhs.text &&
            lhs.placeholder == rhs.placeholder
        }
    }
    
    class DataModel: ObservableObject {
        @Published var data: [MyDataItem] = [
            MyDataItem(placeholder: "text 1"),
            MyDataItem(placeholder: "text 2"),
            MyDataItem(placeholder: "text 3"),
            MyDataItem(placeholder: "text 4")
        ]
    }
    
    struct BugPrepro: View {
    
        @StateObject var model = DataModel()
    
        var body: some View {
            VStack {
                ForEach($model.data) { $dataItem in  // Use binding to pass to LineItemView
                    LineItemView(dataItem: $dataItem) {
                        if let index = model.data.firstIndex(where: { $0.id == dataItem.id }) {
                            model.data.remove(at: index)
                        }
                    }
                }
            }
        }
    }
    
    struct LineItemView: View {
    
        @Binding var dataItem: MyDataItem  // Use @Binding to enable updates
        var onDelete: () -> Void
    
        var body: some View {
            HStack {
                TextField(dataItem.placeholder, text: $dataItem.text)  // Use binding for TextField
                Button(action: onDelete) {
                    Text("Delete")
                        .foregroundColor(.red)
                }
            }
            .padding()
            .border(Color.black)
        }
    }
    
    #Preview {
        BugPrepro()
    }
    
    Login or Signup to reply.
  2. Seems to me ForEach‘s automatic bindings feature is broken in this case of removing the last TextField and we have to wait for it to be fixed. In the meantime you can do it the old way:

    ForEach(data) { dataItem in 
        let binding = Binding<MyDataItem> {
            item
        } set: { newValue in
            if let index = data.firstIndex(where: { i in
                i.id == item.id
            }) {
                data[index] = newValue
            }
        }
        LineItemView(dataItem: binding) {
    

    As for the performance issue of every LineItemView‘s body being called no matter which text is changed, I believe your Equatable implementation is correct – so basically you are trying to make it ignore the onDelete closure which it can’t compare automatically.

    I submitted Feedback FB15573786 and linked here.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search