How can I stop a SwiftUI TextField from losing focus when binding within a ForEach loop?

Viewed 453

In a form, I'd like a user to be able to dynamically maintain a list of phone numbers, including adding/removing numbers as they wish.

I'm currently maintaining the list of numbers in a published array property of an ObservableObject class, such that when a new number is added to the array, the SwiftUI form will rebuild the list through its ForEach loop. (Each phone number is represented as a PhoneDetails struct, with properties for the number itself and the type of phone [work, cell, etc].)

Adding/removing works perfectly fine, but when I attempt to edit a phone number within a TextField, as soon as I type a character, the TextField loses focus.

My instinct is that, since the TextField is bound to the phoneNumber property of one of the array items, as soon as I modify it, the entire array within the class publishes the fact that it's been changed, hence SwiftUI dutifully rebuilds the ForEach loop, thus losing focus. This behavior is not ideal when trying to enter a new phone number!

I've also tried looping over an array of the PhoneDetails objects directly, without using an ObservedObject class as an in-between repository, and the same behavior persists.

Below is the minimum reproducible example code; as mentioned, adding/removing items works great, but attempting to type into any TextField immediately loses focus.

Can someone please help point me in the right direction as to what I'm doing wrong?

class PhoneDetailsStore: ObservableObject {
    @Published var allPhones: [PhoneDetails]
    
    init(phones: [PhoneDetails]) {
        allPhones = phones
    }
    
    func addNewPhoneNumber() {
        allPhones.append(PhoneDetails(phoneNumber: "", phoneType: "cell"))
    }
    
    func deletePhoneNumber(at index: Int) {
        if allPhones.indices.contains(index) {
            allPhones.remove(at: index)
        }
    }
}


struct PhoneDetails: Equatable, Hashable {
    var phoneNumber: String
    var phoneType: String
}


struct ContentView: View {
    
    @ObservedObject var userPhonesManager: PhoneDetailsStore = PhoneDetailsStore(
        phones: [
            PhoneDetails(phoneNumber: "800–692–7753", phoneType: "cell"),
            PhoneDetails(phoneNumber: "867-5309", phoneType: "home"),
            PhoneDetails(phoneNumber: "1-900-649-2568", phoneType: "office")
        ]
    )
    
    var body: some View {
        List {
            ForEach(userPhonesManager.allPhones, id: \.self) { phoneDetails in
                let index = userPhonesManager.allPhones.firstIndex(of: phoneDetails)!
                HStack {
                    Button(action: { userPhonesManager.deletePhoneNumber(at: index) }) {
                        Image(systemName: "minus.circle.fill")
                    }.buttonStyle(BorderlessButtonStyle())
                    TextField("Phone", text: $userPhonesManager.allPhones[index].phoneNumber)
                }
            }
            Button(action: { userPhonesManager.addNewPhoneNumber() }) {
                Label {
                    Text("Add Phone Number")
                } icon: {
                    Image(systemName: "plus.circle.fill")
                }
            }.buttonStyle(BorderlessButtonStyle())
        }
    }
}
1 Answers

try this:

    ForEach(userPhonesManager.allPhones.indices, id: \.self) { index in
        HStack {
            Button(action: {
                userPhonesManager.deletePhoneNumber(at: index)
            }) {
                Image(systemName: "minus.circle.fill")
            }.buttonStyle(BorderlessButtonStyle())
            TextField("Phone", text: $userPhonesManager.allPhones[index].phoneNumber)
        }
    }

EDIT-1:

Reviewing my comment and in light of renewed interest, here is a version without using indices. It uses the ForEach with binding feature of SwiftUI 3 for ios 15+:

class PhoneDetailsStore: ObservableObject {
    @Published var allPhones: [PhoneDetails]
    
    init(phones: [PhoneDetails]) {
        allPhones = phones
    }
    
    func addNewPhoneNumber() {
        allPhones.append(PhoneDetails(phoneNumber: "", phoneType: "cell"))
    }
    
    // -- here --
    func deletePhoneNumber(of phone: PhoneDetails) {
        allPhones.removeAll(where: { $0.id == phone.id })
    }
    
}

struct PhoneDetails: Identifiable, Equatable, Hashable {
    let id = UUID() // <--- here
    var phoneNumber: String
    var phoneType: String
}

struct ContentView: View {
    
    @ObservedObject var userPhonesManager: PhoneDetailsStore = PhoneDetailsStore(
        phones: [
            PhoneDetails(phoneNumber: "800–692–7753", phoneType: "cell"),
            PhoneDetails(phoneNumber: "867-5309", phoneType: "home"),
            PhoneDetails(phoneNumber: "1-900-649-2568", phoneType: "office")
        ]
    )
    
    var body: some View {
        List {
            ForEach($userPhonesManager.allPhones) { $phone in  // <--- here
                HStack {
                    Button(action: {
                        userPhonesManager.deletePhoneNumber(of: phone)  // <--- here
                    }) {
                        Image(systemName: "minus.circle.fill")
                    }.buttonStyle(BorderlessButtonStyle())
                    TextField("Phone", text: $phone.phoneNumber)  // <--- here
                }
            }
            Button(action: { userPhonesManager.addNewPhoneNumber() }) {
                Label {
                    Text("Add Phone Number")
                } icon: {
                    Image(systemName: "plus.circle.fill")
                }
            }.buttonStyle(BorderlessButtonStyle())
        }
    }
}
   
Related