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()
}
Pass a View
to the TableColumn
s, 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)
}
}