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.
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")
]
)
]
)
]
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.