5

I am trying to use iOS 15.0 swipeActions and confirmationDialog to delete an item in a List.

But what happens is that the wrong item gets deleted.

Here is my code:

struct ConversationsSection: View {

@State private var isShowingDeleteActions = false

let items = ["One", "Two", "Three", "Four", "Five"]

var body: some View {
    List(items, id: \.self) { item in
        Text(item)
            .swipeActions(edge: .trailing) {
                Button(role: .destructive) {
                    isShowingDeleteActions = true
                    print("Trying to delete: " + item)
                } label: {
                    Label("Delete", systemImage: "trash")
                }
            }
            .confirmationDialog("Delete item?", isPresented: $isShowingDeleteActions) {
                Button("Confirm Delete", role: .destructive) {
                    print("Actually deleting: " + item)
                    isShowingDeleteActions = false
                }
            }
    }
}

}

The output is:

Trying to delete: Two
Actually deleting: Four
Trying to delete: Five
Actually deleting: Three

So I swipe an item and confirmationDialog is presented. But inside confirmationDialog another item is passed. Why is that?

Tony
  • 716
  • 9
  • 15

3 Answers3

8

I think of it this way: you have a confirmationDialog modifier inside your ForEach loop, so there are multiple confirmation dialogs whose presentation is controlled by a single $isShowingDeleteActions state variable. When that happens, SwiftUI can't reliably show the dialog from the instance of the loop that sets the state variable – so it might end up showing a different dialog, and one whose item value is different.

I get how frustrating it is!

One workaround would be to move the confirmationDialog out of the loop altogether, so there'll only ever be one modifier using $isShowingDeleteActions. The snag there is that there's no longer a direct reference to item, but we could compensate by keeping a reference in a second state variable:

struct ConversationsSection: View {

@State private var isShowingDeleteActions = false
@State private var itemToDelete: Item? = nil

var body: some View {
    List(items, id: \.self) { item in
        Text(item)
            .swipeActions(edge: .trailing) {
                Button(role: .destructive) {
                    itemToDelete = item
                    isShowingDeleteActions = true
                } label: {
                    Label("Delete", systemImage: "trash")
                }
            }
    }
    .confirmationDialog("Delete item?", isPresented: $isShowingDeleteActions) {
        Button("Confirm Delete", role: .destructive) {
            if let item = itemToDelete {
                print("Actually deleting: " + item)
                isShowingDeleteActions = false
                itemToDelete = nil
            }
        }
    }
}
ScottM
  • 7,108
  • 1
  • 25
  • 42
  • This definitely works. Thank you. Holding the value of `itemToDelete` inside another variable until the user confirms deletion is not the most elegant solution, but it works. Your explanation of multiple `confirmationDialog`s makes a lot of sense. – Tony Nov 18 '21 at 12:20
  • Is there a workaround for iPad? The solution works, but now the `confirmationDialog`is always attached to the `List`and not to the correct item inside the `List`. – Esera Feb 09 '22 at 06:52
  • @Esera If you move the contents of the row into a separate custom view, effectively making your list `List { MyCustomRow(item: $0) }`, you can move the confirmationDialog into that custom view. – ScottM Jul 13 '22 at 08:12
5

The solution from Scott works fine, but when you want to show the confirmationDialog on an iPad, the attachment is off (as it is now using the List as attachment). See the screenshot below:

iPad screenshot of attachment problem

You can fix this by extracting the Text together with its modifiers and the @State property into a new view like follows:

import SwiftUI

struct ItemView: View {
    
    @State private var isShowingDeleteActions = false
    
    var item: String
    
    var body: some View {
        Text(item)
            .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
            .swipeActions(edge: .trailing) {
                Button(role: .destructive) {
                    isShowingDeleteActions = true
                    print("Trying to delete: " + item)
                } label: {
                    Label("Delete", systemImage: "trash")
                }
            }
            .confirmationDialog("Delete item?", isPresented: $isShowingDeleteActions) {
                Button("Confirm Delete", role: .destructive) {
                    print("Actually deleting: " + item)
                    isShowingDeleteActions = false
                }
            }
    }
}

struct ConversationsSection: View {

    let items = ["One", "Two", "Three", "Four", "Five"]

    var body: some View {
        NavigationView {
            List(items, id: \.self) { item in
                ItemView(item: item)
            }
        }
    }
}

Screenshot of this:

iPad screenshot, where the attachment is correct

Esera
  • 217
  • 2
  • 10
  • You saved my day!!!! I had so much ugly code with `.alert` with extra @State for managing data to be deleted. I can finally remove my ugly code. Thank you so much!!! – user1046037 Jun 15 '22 at 03:16
  • The shown solution has been working in iOS/iPadOS 15, but no longer on iOS/iPadOS 16/16.1. It seems it does not correctly store the isShowindDeleteActions state in iOS 16. Anybody running this successfully on iOS 16+? – KlausM Nov 10 '22 at 12:00
1

2 ways:

  1. put "@State private var isPresented", ".swipeActions" and ".confirmationDialog" into list item, just like Esera's answer. But there is a problem, confirmationDialog often popover and disappear in a second, then popvoer when swipeActions before trash button clicked. After some research, I found it's because swipeActions-.destructive button. use .none instead, everything is fine. see ConfirmationDialog0.
  2. use @State private var selectedMessage: Int? = nil, set it in swipeActions, use it in confirmationDialog. Problem is this confirmationDialog for whole list, not for a list item, just like Esera's answer.

check my test code:

struct ConfirmationDialog: View {
    var body: some View {
        ScrollView {
            ConfirmationDialog3()
            ConfirmationDialog0()
            ConfirmationDialog1()
            ConfirmationDialog2()
        }
    }
}

struct ConfirmationDialog3: View {
    @State private var messages: [Int] = (0..<5).map { $0 }
    @State private var confirmationShown = false
    @State private var selectedMessage: Int? = nil
    
    var body: some View {
        NavigationView {
            List {
                ForEach(messages, id: \.self) { message in
                    Text("\(message)")
                        .swipeActions {
                            Button(
                                role: .destructive,
                                action: {
                                    selectedMessage = message
                                    confirmationShown = true
                                }
                            ) {
                                Image(systemName: "trash")
                            }
                        }
                }
            }
            .navigationTitle("YEAH!")
            .confirmationDialog(
                "Are you sure?",
                isPresented: $confirmationShown,
                titleVisibility: .visible,
                presenting: selectedMessage
            ) { message in
                Button("Yes, delete: \(message)") {
                    withAnimation {
                        messages = messages.filter { $0 != message }
                    }
                }.keyboardShortcut(.defaultAction)
                Button("No", role: .cancel) {}
            } message: { message in
                Text("\(message)")
            }
        }
    }
}

struct ConfirmationDialog0: View {
    @State private var messages: [Int] = (0..<5).map { $0 }
    
    var body: some View {
        NavigationView {
            List {
                ForEach(messages, id: \.self) { message in
                    itemView0(message: message) { _ in
                        withAnimation {
                            messages = messages.filter { $0 != message }
                        }
                    }
                }
            }
            .navigationTitle("WRONG if .destructive!")
        }
    }
}

struct itemView0: View {
    @State private var confirmationShown = false
    let message: Int
    let onDelete: (Int) -> Void
    
    var body: some View {
        Text("\(message)")
            .swipeActions {
                Button(
                    role: .none,//.destructive,
                    action: { confirmationShown = true }
                ) {
                    Image(systemName: "trash")
                }
                .tint(Color.red)
            }
            .confirmationDialog(
                "Are you sure?",
                isPresented: $confirmationShown,
                titleVisibility: .visible,
                presenting: message
            ) { message in
                Button("Yes, delete: \(message)") {
                    onDelete(message)
                }.keyboardShortcut(.defaultAction)

                Button("No", role: .cancel) {}
            } message: { message in
                Text("\(message)")
            }
    }
}

struct ConfirmationDialog1: View {
    @State private var messages: [Int] = (0..<5).map { $0 }
    @State private var confirmationShown = false
    
    var body: some View {
        NavigationView {
            List {
                ForEach(messages, id: \.self) { message in
                    Text("\(message)")
                        .swipeActions {
                            Button(
                                role: .destructive,
                                action: { confirmationShown = true }
                            ) {
                                Image(systemName: "trash")
                            }
                        }
                        .confirmationDialog(
                            "Are you sure?",
                            isPresented: $confirmationShown,
                            titleVisibility: .visible,
                            presenting: message
                        ) { message in
                            Button("Yes, delete: \(message)") {
                                withAnimation {
                                    messages = messages.filter { $0 != message }
                                }
                            }.keyboardShortcut(.defaultAction)

                            Button("No", role: .cancel) {}
                        } message: { message in
                            Text("\(message)")
                        }
                }
            }
            .navigationTitle("WRONG!")
        }
    }
}

struct ConfirmationDialog2: View {
    @State private var showingConfirmation = false
    @State private var backgroundColor = Color.white
    
    var body: some View {
        Text("Hello, World!")
            .frame(maxWidth: .infinity)
            .frame(height: 100)
            .background(backgroundColor)
            .onTapGesture {
                showingConfirmation = true
            }
            .confirmationDialog("Change background", isPresented: $showingConfirmation) {
                Button("Red") { backgroundColor = .red }
                Button("Green") { backgroundColor = .green }
                Button("Blue") { backgroundColor = .blue }
                Button("Cancel", role: .cancel) { }
            } message: {
                Text("Select a new color")
            }
    }
}

struct ConfirmationDialog_Previews: PreviewProvider {
    static var previews: some View {
        ConfirmationDialog()
    }
}
foolbear
  • 726
  • 1
  • 7
  • 19