swiftui

Get SwiftUI Table single-clicked (column, row) pair on macOS


When user single-clicks a SwiftUI Table on macOS, I'd like to know the clicked (column, row) pair, or point. I added onTapGesture() handler to the Table, but it's never invoked. How can I figure out which (column, row) was clicked?

My goal is to implement cell selection in my view model. I need to be able to determine the clicked (column, row) pair in order to select the correct cell in the view model.

Below is Apple example code, to which I added the ineffective onTapGesture() handler.

struct Person: Identifiable {
    let givenName: String
    let familyName: String
    let emailAddress: String
    let id = UUID()
}
struct PeopleTable: View {
    @State private var people = [
        Person(givenName: "Juan", familyName: "Chavez", emailAddress: "juanchavez@icloud.com"),
        Person(givenName: "Mei", familyName: "Chen", emailAddress: "meichen@icloud.com"),
        Person(givenName: "Tom", familyName: "Clark", emailAddress: "tomclark@icloud.com"),
        Person(givenName: "Gita", familyName: "Kumar", emailAddress: "gitakumar@icloud.com")
    ]
    var body: some View {
        Table(people) {
            TableColumn("Given Name", value: \.givenName)
            TableColumn("Family Name", value: \.familyName)
            TableColumn("E-Mail Address", value: \.emailAddress)
        }
        .onTapGesture(perform: { p in
            print("clicked point \(p)")
        })
    }
}
#Preview {
    PeopleTable()
}

Solution

  • Pass a View to the TableColumns, and add the tap gesture to that.

    Table(people) {
        TableColumn("Given Name") { person in
            Text(person.givenName)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
                .contentShape(.rect)
                .onTapGesture {
                    print("Clicked given name of \(person.id)")
                }
        }
        TableColumn("Family Name") { person in
            Text(person.familyName)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
                .contentShape(.rect)
                .onTapGesture {
                    print("Clicked family name of \(person.id)")
                }
        }
        TableColumn("E-Mail Address") { person in
            Text(person.emailAddress)
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
                .contentShape(.rect)
                .onTapGesture {
                    print("Clicked email address of \(person.id)")
                }
        }
    }
    

    I have used frame and contentShape to increase the tappable as much as possible. Without those, the empty parts of the table cells are not tappable.

    If this is still unsatisfactory, I would recommend wrapping a NSTableView with a NSViewRepresentable. You would set its action to a method, then access clickedColumn and clickedRow in that method.

    Here is a simple demonstration:

    struct PeopleTableRepresentable: NSViewRepresentable {
        let people: [Person]
        let onClick: (Int, Int) -> Void
        
        static let givenNameCol = NSUserInterfaceItemIdentifier("givenName")
        static let familyNameCol = NSUserInterfaceItemIdentifier("familyName")
        static let emailAddressCol = NSUserInterfaceItemIdentifier("emailAddress")
        
        func makeNSView(context: Context) -> NSScrollView {
            let scrollView = NSScrollView()
            let tableView = NSTableView()
            scrollView.documentView = tableView
            scrollView.hasVerticalScroller = true
            scrollView.horizontalScrollElasticity = .none
            let givenNameCol = NSTableColumn(identifier: Self.givenNameCol)
            givenNameCol.title = "Given Name"
            tableView.addTableColumn(givenNameCol)
            let familyNameCol = NSTableColumn(identifier: Self.familyNameCol)
            familyNameCol.title = "Family Name"
            tableView.addTableColumn(familyNameCol)
            let emailAddressCol = NSTableColumn(identifier: Self.emailAddressCol)
            emailAddressCol.title = "Email Address"
            tableView.addTableColumn(emailAddressCol)
            tableView.delegate = context.coordinator
            tableView.dataSource = context.coordinator
            
            let contextMenu = NSMenu(title: "Context Menu")
            tableView.menu = contextMenu
            tableView.action = #selector(Coordinator.tableViewAction)
            tableView.target = context.coordinator
            context.coordinator.tableView = tableView
            return scrollView
        }
        
        func makeCoordinator() -> Coordinator {
            .init()
        }
        
        func updateNSView(_ nsView: NSScrollView, context: Context) {
            context.coordinator.people = people
            context.coordinator.onClick = onClick
            context.coordinator.tableView?.reloadData()
        }
        
        func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? {
            proposal.replacingUnspecifiedDimensions()
        }
        
        @MainActor
        class Coordinator: NSObject, NSTableViewDelegate, NSTableViewDataSource {
            var people: [Person] = []
            var onClick: ((Int, Int) -> Void)?
            weak var tableView: NSTableView?
            
            func numberOfRows(in tableView: NSTableView) -> Int {
                people.count
            }
            
            func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool {
                false
            }
            
            func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
                func stringValue() -> String {
                    if tableColumn?.identifier == PeopleTableRepresentable.givenNameCol {
                        people[row].givenName
                    } else if tableColumn?.identifier == PeopleTableRepresentable.familyNameCol {
                        people[row].familyName
                    } else if tableColumn?.identifier == PeopleTableRepresentable.emailAddressCol {
                        people[row].emailAddress
                    } else {
                        ""
                    }
                }
                if let tableCell = tableView.makeView(withIdentifier: .init("itemCell"), owner: self) as? NSTableCellView,
                   let textField = tableCell.textField {
                    textField.stringValue = stringValue()
                    return tableCell
                } else {
                    let textField = NSTextField()
                    textField.backgroundColor = NSColor.clear
                    textField.translatesAutoresizingMaskIntoConstraints = false
                    textField.isBordered = false
                    textField.lineBreakMode = .byTruncatingTail
                    
                    // Create a cell
                    let newCell = NSTableCellView()
                    newCell.identifier = .init("itemCell")
                    newCell.addSubview(textField)
                    newCell.textField = textField
                    
                    NSLayoutConstraint.activate([
                        textField.centerYAnchor.constraint(equalTo: newCell.centerYAnchor),
                        textField.leftAnchor.constraint(equalTo: newCell.leftAnchor),
                        textField.rightAnchor.constraint(equalTo: newCell.rightAnchor),
                    ])
                    
                    textField.stringValue = stringValue()
                    textField.isEditable = false
                    textField.maximumNumberOfLines = 1
                    return newCell
                }
            }
            
            @objc func tableViewAction() {
                if let onClick, let tableView {
                    onClick(tableView.clickedColumn, tableView.clickedRow)
                }
            }
        }
    }
    
    // ...
    
    var body: some View {
        PeopleTableRepresentable(people: people) { col, row in
            print(col, row)
        }
    }