SwiftyYns
SwiftyYns

Reputation: 15

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.

Upvotes: 0

Views: 368

Answers (2)

Daniel
Daniel

Reputation: 1057

If you are on macOS I don't think you need NaviagationLink at all if you have a NavigationSplitView. Here is my example, which also works on iPhones where only the first column of a table is being shown. Only for that I need a NavigationStack. This only applies if you are working with Table. Using List, this is not needed.

struct Item: Identifiable, Hashable {
    let id = UUID()
    let make: String
    let model: String
    let date = "\(Date())"
}

struct ContentView: View {
    @State var selectedItemID: Item.ID?

    #if os(iOS)
        @Environment(\.horizontalSizeClass) private var horizontalSizeClass
        private var isCompact: Bool { horizontalSizeClass == .compact }
    #else
        private let isCompact = false
    #endif

    var body: some View {
        NavigationSplitView {
            if isCompact {
                NavigationStack {
                    TableView(selectedItemID: $selectedItemID)
                        .navigationDestination(item: $selectedItemID) { itemId in
                            DetailView(itemId: itemId)
                        }
                }
            }
            else {
                TableView(selectedItemID: $selectedItemID)
            }
        } detail: {
            if let selectedItemID {
                DetailView(itemId: selectedItemID)
            }
            else {
                Text("Nothing is selected")
            }
        }
    }
}

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)]
    @Binding var selectedItemID: Item.ID?

    var body: some View {
        VStack {
            Table(of: Item.self, selection: $selectedItemID, sortOrder: $sorting) {
                TableColumn("Make", value: \.make)
                TableColumn("Model", value: \.model)
                TableColumn("Date", value: \.date)
            } rows: {
                ForEach(items) { item in
                    TableRow(item)
                }
            }
        }
    }
}

#Preview {
    ContentView()
}

Upvotes: 0

Sweeper
Sweeper

Reputation: 273540

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)
                    }
            }
        }

    }
}

Upvotes: 2

Related Questions