macosswiftuiswiftui-navigationlink

SwiftUi macOs: How to use a row in a Table() as a NavigationLink()


I'm trying to build an App with two rows NavigationSplitView. The sidebar is used to have categories and the main section as a table (!) of items. When an item (=row) in this table is doubleClicked I want to navigate to a "detailsView" of that particular item. The first approach of the app used a List where dozens of examples are flying around how to use NavigationLink() and .navigationDestination with it, however I couldn't find any working example for swiftUi Table().

The most logical approach seems to me to use "Table(of: ...) {} rows: {ForEach}" appoach where in the ForEach loop the NavigationLinks could be added. But this didn't work. Below an code example to ease the dicsussion:

struct TableView: View {
  @State private var items: [Item] = []

  @State private var sorting = [KeyPathComparator(\item.model)]
  @State private var selection: item.ID?
  
  var body: some View {
    VStack{
      Table(of: Item.self, selection: $selection, sortOrder: $sorting) {
        TableColumn("Make",  value: \.make)
        TableColumn("Model", value: \.model)
        TableColumn("Date",  value: \.date)
      } rows: {
        ForEach(items) { item in
          TableRow(item)
          NavigationLink(destination: DetailView(itemId: selection))
        }
      }
   }

However it seems this is not allowed, the compiler throws "No exact matches in reference to static method 'buildExpression'" if I add the line of code with NavigationLink.


Solution

  • You should not use NavigationLink here, because table rows are not Views and NavigationLinks cannot be table rows. Each row of the table consists of multiple Views, one for each column. Plus, you want to detect double-click.

    You should use contextMenu(forSelectionType:menu:primaryAction:), and programmatically navigate in the primaryAction closure. This is what will be called when the user double-clicks a table row.

    For example, the table can take a Binding<NavigationPath> and append the selected item ID to it.

    @Binding var path: NavigationPath
    
    Table(of: Item.self, selection: $selection, sortOrder: $sorting) {
        TableColumn("Make",  value: \.make)
        TableColumn("Model", value: \.model)
        TableColumn("Date",  value: \.date)
    } rows: {
        ForEach(items) { item in
            TableRow(item)
        }
    }
    .contextMenu(forSelectionType: Item.ID.self) { _ in } primaryAction: { items in
        guard !items.isEmpty, let selection else { return }
    
        // this will push a new view onto the NavigationStack for each double-click
        path.append(selection)
    }
    

    Here is a complete example:

    struct Item: Identifiable, Hashable {
        let id = UUID()
        let make: String
        let model: String
        let date = "\(Date())"
    }
    
    struct ContentView: View {
        @State var path = NavigationPath()
        
        var body: some View {
            NavigationSplitView {
                TableView(path: $path)
            } detail: {
                NavigationStack(path: $path) {
                    Text("Nothing is selected")
                        .navigationDestination(for: Item.ID.self) { itemId in
                            DetailView(itemId: itemId)
                        }
                }
            }
    
        }
    }
    
    struct DetailView: View {
        let itemId: UUID
        var body: some View {
            Text(itemId.uuidString)
        }
    }
    
    struct TableView: View {
        @State private var items: [Item] = [
            Item(make: "A", model: "1"),
            Item(make: "B", model: "2"),
            Item(make: "C", model: "3"),
            Item(make: "D", model: "4"),
        ]
        
        @State private var sorting = [KeyPathComparator(\Item.model)]
        @State private var selection: Item.ID?
        
        @Binding var path: NavigationPath
        
        var body: some View {
            VStack{
                Table(of: Item.self, selection: $selection, sortOrder: $sorting) {
                    TableColumn("Make",  value: \.make)
                    TableColumn("Model", value: \.model)
                    TableColumn("Date",  value: \.date)
                } rows: {
                    ForEach(items) { item in
                        TableRow(item)
                    }
                }
                .contextMenu(forSelectionType: Item.ID.self) { _ in } primaryAction: { items in
                    guard !items.isEmpty, let selection else { return }
                    path.append(selection)
                }
    
            }
        }
    }
    

    It doesn't have to be value-based navigation. You can also use navigationDestination(item:destination:). For example:

    struct ContentView: View {
        @State var doubleClickedItemId: Item.ID?
        
        var body: some View {
            NavigationSplitView {
                // change TableView accordingly so that it takes a Binding<Item.ID?>
                // and set it in primaryAction
                TableView(doubleClickedItemId: $doubleClickedItemId)
            } detail: {
                NavigationStack {
                    Text("Nothing is selected")
                        .navigationDestination(item: $doubleClickedItemId) { itemId in
                            DetailView(itemId: itemId)
                        }
                }
            }
    
        }
    }