Search code examples
swiftmacosswiftuiswiftui-list

How to identify selected SwiftUI list item


In a macOS SwiftUI app, I have a List of items with context menus. When a menu selection is made, the app needs to act on the correct list item. (The context menu can apply to any item, not just the selected one.)

I have a solution that works fairly well, but it has a strange bug. When you right click (or Command+click) on an item, the app sets a variable indicating which item was clicked, and also sets a flag. The flag triggers a sheet requesting confirmation of the action. The problem is that the first time you select a menu item, the sheet doesn’t use the saved item as it should. You can see because the item’s name is not in the “Ok to delete” prompt. If you close that first sheet and select another item, it works correctly, and it works for for every subsequent item from then on, even the first one you tried. It doesn’t matter which item you try first, or whether you select the item first, or anything.

import SwiftUI

struct ContentView: View {
    @State private var actionTarget = Value(name: "")
    @State private var isDeleting = false
    @State private var selection = Value(name: "")
    
    struct Value: Identifiable, Hashable {
        let id = UUID()
        var name: String
    }
    let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]

    var body: some View {
        List(values, selection: $selection) { value in
            Text (value.name)
                .tag(value)
                .contextMenu(ContextMenu {
                    Button {
                        actionTarget = value
                        isDeleting = true
                    } label: { Text("Delete \(value.name)") }
                })
        }
        .sheet(isPresented: $isDeleting) {
            Text("Ok to delete \"\(actionTarget.name)?\"")
                .frame(width: 300)
            .padding()
            .toolbar {
               ToolbarItem(placement: .cancellationAction) {
                   Button("Cancel") { isDeleting = false }
               }
                ToolbarItem(placement: .destructiveAction) {
                   Button {
                       //TODO: Delete
                       isDeleting = false
                   } label: { Text("Delete") }
               }
            }
        }
    }
}

Solution

  • This is a bug in SwiftUI.

    You can work around it by using a different version of the sheet modifier, the one that takes a Binding<Item?>. That also has the advantage that it leads you to a better data model. In your model as posted, you have separate isDeleting and actionTarget variables which can be out of sync. Instead, use a single optional variable holding the Value to be deleted, or nil if there is no deletion to be confirmed.

    struct ContentView: View {
        @State private var deleteRequest: Value? = nil
        @State private var selection: Value? = nil
    
        struct Value: Identifiable, Hashable {
            let id = UUID()
            var name: String
        }
        let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]
    
        var body: some View {
            List(values, selection: $selection) { value in
                Text(value.name)
                    .tag(value)
                    .contextMenu(ContextMenu {
                        Button {
                            deleteRequest = value
                        } label: { Text("Delete \(value.name)") }
                    })
            }
            .sheet(
                item: $deleteRequest,
                onDismiss: { deleteRequest = nil }
            ) { item in
                Text("Ok to delete \"\(item.name)?\"")
                    .frame(width: 300)
                    .padding()
                    .toolbar {
                        ToolbarItem(placement: .cancellationAction) {
                            Button("Cancel") {
                                deleteRequest = nil
                            }
                        }
                        ToolbarItem(placement: .destructiveAction) {
                            Button {
                                print("TODO: delete \(item)")
                                deleteRequest = nil
                            } label: { Text("Delete") }
                        }
                    }
            }
        }
    }
    

    But the use of a toolbar inside the sheet doesn't look like a normal macOS confirmation sheet. Instead, you should use confirmationDialog.

    struct ContentView: View {
        @State private var deleteRequest: Value? = nil
        @State private var selection: Value? = nil
    
        struct Value: Identifiable, Hashable {
            let id = UUID()
            var name: String
        }
        let values = [Value(name: "One"), Value(name: "Two"), Value(name: "Three")]
    
        var body: some View {
            List(values, selection: $selection) { value in
                Text(value.name)
                    .tag(value)
                    .contextMenu(ContextMenu {
                        Button {
                            deleteRequest = value
                        } label: { Text("Delete \(value.name)") }
                    })
            }
            .confirmationDialog(
                "OK to delete \(deleteRequest?.name ?? "(nil)")?",
                isPresented: .constant(deleteRequest != nil),
                presenting: deleteRequest,
                actions: { item in
                    Button("Cancel", role: .cancel) { deleteRequest = nil }
                    Button("Delete", role: .destructive) {
                        print("TODO: delete \(item)")
                        deleteRequest = nil
                    }
                }
            )
        }
    }