Search code examples
swiftswiftui

SwiftUI List Swipe To Delete Selects Random Element


I have a SwiftUI app that utilizes swipe to delete on a list of users. My problem is that when I do this, a random element of the array gets selected and deleted.

My first thought was that the index was getting assigned erroneously, but stepping through the code I think the index function is working properly. It seems to be something in the selection of the row.

Any thoughts?

Here's my code:

import SwiftUI

struct AdminUserTableView: View {
    @State var userViewModel = UserViewModel.shared
    
    @State var deleteAdminUserMessage = ""
    @State var deleteAdminAlert = false
    @State var deleteConfirm = false
    
    @State var getUsersMessage = ""
    @State var getUsersAlert = false
    
    var body: some View {
        Text("")
            .navigationTitle("Admin Users")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    NavigationLink
                    {
                        AddUserView()
                    } label: {
                        Text("+")
                            .font(.largeTitle)
                    }
                }
            }
        
        List {
            ForEach(userViewModel.adminUsers, id: \.self) { user in
                AdminUserCellView(user: user)
                    .swipeActions {
                        Button("Delete") {
                            deleteConfirm = true
                        }
                        .tint(.red)
                    }
                    .confirmationDialog("Are you sure you want to delete \(user.email)?", isPresented: $deleteConfirm, titleVisibility: .visible) {
                        Button("Delete", role: .destructive) {
                            
                            Task {
                                do {
//Delete user from server
                                    var httpStatus = try await NetworkController.shared.deleteAdminUser(user: user)
                                    
                                    let index = UserViewModel.shared.adminUsers.firstIndex(where: { $0.id == user.id })!
//Delete user from array.
                                    userViewModel.removeUser(at: index)
                                } catch {
                                    deleteAdminUserMessage = "Unable to delete user. Please try again."
                                    deleteAdminAlert = true
                                }
                            }
                        }
                        
                        Button("Cancel", role: .cancel) {}
                    }
            }

        }
        .alert(deleteAdminUserMessage, isPresented: $deleteAdminAlert) {
                    Button("OK", role: .cancel) { }
                }
        .onAppear {
            Task {
                do {
                    UserViewModel.shared.adminUsers = try await NetworkController.shared.getAdminUsers()
                } catch {
                    getUsersMessage = "Failed to get admin users from server. Go back, then try again."
                    getUsersAlert = true
                }
            }
        }
    }
}

Here's UserViewModel:

import Foundation

@Observable class UserViewModel {
    static var shared = UserViewModel()
    
    var adminUsers: [User] = []
    var residentUsers: [ResidentUser] = []
    
    func addAdminUser(_ user: User) {
        adminUsers.append(user)
    }
    
    func removeUser(at index: Int) {
        adminUsers.remove(at: index)
    }
}

Edited to add: id: .self to the forEach. This did not change anything.

I found a potential partial solution, to bind the confirmation dialogue to the user. The problem remains that it's still not deleting the correct user. But it solves half the problem. Here's the code:

import SwiftUI

struct AdminUserTableView: View {
    @State private var userViewModel = UserViewModel()
    
    @State var deleteAdminUserMessage = ""
    @State var deleteAdminAlert = false
//    @State var deleteConfirm = false
    @State var userToDelete: User? = nil
    
    @State var getUsersMessage = ""
    @State var getUsersAlert = false
    
    var body: some View {
        Text("")
            .navigationTitle("Admin Users")
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    NavigationLink
                    {
                        AddUserView()
                    } label: {
                        Text("+")
                            .font(.largeTitle)
                    }
                }
            }
        
        List {
            ForEach(userViewModel.adminUsers, id: \.self) { user in
                AdminUserCellView(user: user)
                    .swipeActions {
                        Button("Delete") {
                            userToDelete = user
                        }
                        .tint(.red)
                    }
                    .confirmationDialog("Are you sure you want to delete \(userToDelete?.email ?? "")?", isPresented: Binding<Bool>(
                                                get: { userToDelete != nil },
                                                set: { if !$0 { userToDelete = nil } }
                                            ), titleVisibility: .visible) {
                        Button("Delete", role: .destructive) {
                            
                            if let userToDelete = userToDelete {
                                Task {
                                    do {
                                        var httpStatus = try await NetworkController.shared.deleteAdminUser(user: user)
                                        
                                        if httpStatus.statusCode == 200 {
                                            let index = userViewModel.adminUsers.firstIndex(where: { $0.id == user.id })!
                                            userViewModel.removeUser(at: index)
                                        }
                                        
                                    } catch {
                                        deleteAdminUserMessage = "Unable to delete user. Please try again."
                                        deleteAdminAlert = true
                                    }
                                }
                            }
                        }
                        
                        Button("Cancel", role: .cancel) {}
                    }
            }

        }
        .alert(deleteAdminUserMessage, isPresented: $deleteAdminAlert) {
                    Button("OK", role: .cancel) { }
                }
        .onAppear {
            Task {
                do {
                    userViewModel.adminUsers = try await NetworkController.shared.getAdminUsers()
                } catch {
                    getUsersMessage = "Failed to get admin users from server. Go back, then try again."
                    getUsersAlert = true
                }
            }
        }
    }
}

Solution

  • Try this approach using one source of truth data model, @State private var userViewModel = UserViewModel(), and a dedicated @State private var selected: User? to ensure the correct deletion of the desired user.

    Example code:

    struct ContentView: View {
        var body: some View {
            NavigationStack {
                AdminUserTableView()
            }
        }
    }
    
    struct AdminUserTableView: View {
        @State private var userViewModel = UserViewModel() // <--- here
        
        @State private var deleteAdminUserMessage = ""
        @State private var deleteAdminAlert = false
        @State private var deleteConfirm = false
        
        @State private var getUsersMessage = ""
        @State private var getUsersAlert = false
        
        @State private var selected: User?  // <--- here
        
        var body: some View {
            Text("xxxxx")  // for my testing
                .navigationTitle("Admin Users")
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) {
                        NavigationLink
                        {
                           // AddUserView()
                            Text("AddUserView") 
                        } label: {
                            Text("+")
                                .font(.largeTitle)
                        }
                    }
                }
            
            List {
                ForEach(userViewModel.adminUsers) { user in
                  //  AdminUserCellView(user: user)
                    Text(user.name)
                        .swipeActions {
                            Button("Delete") {
                                selected = user  // <--- here
                                deleteConfirm = true
                            }
                            .tint(.red)
                        }
                        .confirmationDialog("Are you sure you want to delete \(selected?.name ?? "not valid")?", isPresented: $deleteConfirm, titleVisibility: .visible) {
                            Button("Delete", role: .destructive) {
                                Task {
                                    do {
                                        if let selected {  // <--- here
                                        //Delete user from server
                                        //var httpStatus = try await NetworkController.shared.deleteAdminUser(user: selected)
    
                                            userViewModel.deleteAdminUser(selected)
                                        }
                                        
                                    } catch {
                                        deleteAdminUserMessage = "Unable to delete user. Please try again."
                                        deleteAdminAlert = true
                                    }
                                }
                            }
                            
                            Button("Cancel", role: .cancel) {}
                        }
                }
                
            }
            .alert(deleteAdminUserMessage, isPresented: $deleteAdminAlert) {
                Button("OK", role: .cancel) { }
            }
            .task {  // <--- here
                do {
                    // userViewModel.adminUsers = try await NetworkController.shared.getAdminUsers()
                    
                    // for my testing
                    userViewModel.adminUsers = [User(name: "user-0"),User(name: "user-1"),User(name: "user-2"),User(name: "user-3"),User(name: "user-4"),User(name: "user-5")]
                } catch {
                    getUsersMessage = "Failed to get admin users from server. Go back, then try again."
                    getUsersAlert = true
                }
            }
        }
    }
    
    @Observable class UserViewModel {
        var adminUsers: [User] = []
        var residentUsers: [ResidentUser] = []
        
        func deleteAdminUser(_ user: User) {
            if let index = adminUsers.firstIndex(where: { $0.id == user.id }) {
                adminUsers.remove(at: index)
            }
        }
    }
    
    struct User: Identifiable {
        let id = UUID()
        var name: String
    
        init(name: String) {
            self.name = name
        }
    }
    
    struct ResidentUser: Identifiable {
        let id = UUID()
        var name: String
    
        init(name: String) {
            self.name = name
        }
    }