Search code examples
swiftuicontextmenuswiftui-listswiftui-navigationview

SwiftUI No ContextMenu on Recursive List


I am trying to port a program written in Java to SwiftUI. The basic structure of the UI is a navigation tree on the left side of the main view. The user might select one item and the several details will be displayed in tables on the right. The nodes in the navigation tree allow different actions - depending on their type. These actions should be implemented as context menus. Screen shot

I have implemented a small example, but I could not get the context menu to work.

I guess that I am using a wrong approach. Here is my example code:

import SwiftUI

struct ContentView: View {

  @State var selection = Set<Tree<String>>()

  var body: some View {
    NavigationView {
      List(treeNodes, id: \.value, children: \.children, selection: $selection) { tree in
        NavigationLink {
          TabView {
            Tab("Details 1", image: "Block") {
              Text(tree.value)
            }
            Tab("Details 2", image: "Haus") {
              Text(tree.value)
            }
          }
        } label: {
          Label(tree.value, image: tree.icon!)
        }
      }
      .contextMenu(forSelectionType: Tree<String>.self) { nodes in
         if nodes.count == 1 {
          Button("One node selected") {}
        } else {
          Button("No node or more than 1 selected") {}
        }
      }
      .listStyle(SidebarListStyle())
    }
  }
}

#Preview {
  ContentView()
}

For completing the code

import SwiftUI

// MARK: - Tree
/// Class for arbitrary trees. Values must be hashable
class Tree<Value: Hashable>: Hashable {

  var value: Value
  var icon: String?
  var children: [Tree]?

  init(value: any Hashable, icon: String? = nil, children: [Tree]? = nil) {
    // swiftlint: disable force_cast
    self.value = value as! Value
    // swiftlint: enable force_cast
    self.icon = icon
    self.children = children
  }

  static func == (lhs: Tree<Value>, rhs: Tree<Value>) -> Bool {
    return lhs.value == rhs.value
  }

  func hash(into hasher: inout Hasher) {
    hasher.combine(value)
  }
}

// MARK: - Value
/// Class for the stored value
class Value: Hashable, Equatable {
  var id = UUID()

  func hash(into hasher: inout Hasher) {
    hasher.combine(id)
  }

  static func == (lhs: Value, rhs: Value) -> Bool {
    return lhs.id == rhs.id
  }
}

// MARK: - init demo data
/// example data using string values
let treeNodes: [Tree<String>] = [
  .init(
    value: "Root", icon: "Block",
    children: [
      .init(value: "House 1", icon: "Haus"),
      .init(value: "House 2", icon: "Haus"),
      .init(
        value: "House 3", icon: "Haus",
        children: [
          .init(value: "Apartment 1", icon: "Wohnung"),
          .init(value: "Apartment 2", icon: "Wohnung")
        ]
      )
    ]
  )
]

Solution

  • You should not mix the new APIs with the old APIs. Use a NavigationSplitView instead of a NavigationView. After that, the code works as expected.

    NavigationSplitView {
        List(treeNodes, id: \.value, children: \.children, selection: $selection) { tree in
            NavigationLink(value: tree) {
                // why make icon optional when you are going to force unwrap it?
                Label(tree.value, image: tree.icon!)
            }
        }
        .contextMenu(forSelectionType: Tree<String>.self) { nodes in
            if nodes.count == 1 {
                Button("One node selected") {}
            } else {
                Button("No node or more than 1 selected") {}
            }
        }
        .listStyle(.sidebar)
    } detail: {
        // I'm not sure if TabViews in a detail view is officially supported.
        // consider using a segmented Picker to act as tabs instead.
        TabView {
            // it is unclear what detail view you want to show when multiple items are selected, 
            // so here I have just used a random one from the set
            if let tab = selection.first {
                Tab("Details 1", image: "Block") {
                    Text(tab.value)
                }
                Tab("Details 2", image: "Haus") {
                    Text(tab.value)
                }
            } else {
                Tab {
                    Text("No selection")
                }
            }
        }
    }
    

    Also consider changing Tree to a struct, as they are much easier to work with in SwiftUI. You just need:

    struct Tree<Value: Hashable>: Hashable {
        var value: Value
        var icon: String?
        var children: [Tree]?
    }
    

    If you really want Tree to be a class, you should make it @Observable, or else SwiftUI will not be able to see changes in value, icon, children in order to update the view.

    I'm not sure what the Value class is trying to accomplish.