After finding that I was unable to get any standard SwiftUI components to work properly for me I did the following.
Note that this works with Core Data to preserve the expanded state and selected node between sessions.
The list is also presented in an unlimited depth hierarchy.
I have only include the NodeView class which is the key component that returns the expanded folder and its children or just the leaf node if children is NIL. And empty children[] array will still show the disclosure indicator (arrow for expanding).
Hope this helps someone - I have a complete working demo app if required.
Here is what it looks like on an iPad

Or on a Mac (catalyst)

and macOS

import SwiftUI
struct NodeHierarchyView: View {
@ObservedObject var option: MenuOption
@EnvironmentObject var data: DataProvider
@Environment(\.defaultMinListRowHeight) var rowHeight: CGFloat
@State var selectedNode: Node?
@State private var expanded: Set<UUID> = []
var colWidth: CGFloat = 300.0
var body: some View {
if data.selectedOption != nil {
list
} else {
VStack(spacing:0) {
Spacer()
Text("No selection nodes")
Spacer()
}.modifier(TopBorderStyled())
}
}
var list: some View {
HStack {
ScrollViewReader { proxy in
List(selection: $option.selectedNode) {
ForEach(option.nodes, id:\.name) { node in
NodeView(option: option, node: node, level: 0)
}
}
.frame(width:colWidth)
.listStyle(.sidebar)
.onAppear(perform: {
proxy.scrollTo("Doc Item 3.2.10", anchor: .center)
})
}
VStack {
if selectedNode == nil {
Text("No selection nodes")
} else {
Text(selectedNode!.name)
}
}.frame(maxWidth:.infinity)
}
.modifier(TopBorderStyled())
.navigationBarTitleDisplayMode(.inline)
}
func addItem(){
print("Add item")
}
}
struct NodeHierarchyView_Previews: PreviewProvider {
static var previews: some View {
let dataProvider = DataProvider()
dataProvider.selectedFile = dataProvider.files[0]
dataProvider.selectedOption = dataProvider.selectedFile?.sidebarMenu[0].name
return NodeHierarchyView(option: dataProvider.selectedFile!.sidebarMenu[0])
.environmentObject(dataProvider)
}
}
struct NodeView: View {
@ObservedObject var option: MenuOption
@ObservedObject var node: Node
var level: Int
@Environment(\.defaultMinListRowHeight) var rowHeight: CGFloat
let disclosureSize: CGFloat = 12
var indent: CGFloat {
return CGFloat(level) * 15.0
}
var body: some View {
if node.children != nil {
group
} else {
leaf
}
if node.isExpanded {
list
}
}
var nodeViewList: some View {
if node.children == nil {
return AnyView(leaf)
} else {
return AnyView(list)
}
}
var nodeView: some View {
if node.children == nil {
return AnyView(leaf)
} else {
return AnyView(group)
}
}
var group: some View {
HStack {
HStack {
Spacer().frame(width: indent)
Text(node.name)
Spacer()
}.frame(maxWidth: .infinity)
.onTapGesture {
withAnimation {
option.selectNode(node)
}
}
Button(action: {
withAnimation {
node.isExpanded.toggle()
}
print("\(node.name) isExpanded:\(node.isExpanded ? "YES" : "NO")")
}, label: {
if node.isExpanded {
Image(systemName: "chevron.down").resizable().aspectRatio(nil, contentMode: .fit).foregroundColor(Color.systemBlue).frame(width: disclosureSize, height:disclosureSize)
} else {
Image(systemName: "chevron.forward").resizable().aspectRatio(nil, contentMode: .fit).foregroundColor(Color.systemBlue).frame(width: disclosureSize, height:disclosureSize)
}
})
}.frame(maxWidth: .infinity)
.frame(maxHeight: rowHeight)
.listRowSeparator(.hidden)
.listRowBackground(RoundedRectangle(cornerRadius: 10).fill((node.isSelected) ? Color.selectedColor : Color.clear))
}
var leaf: some View {
HStack {
Spacer().frame(width: indent)
Text(node.name)
Spacer()
}.frame(maxWidth: .infinity)
.frame(maxHeight: rowHeight)
.listRowSeparator(.hidden)
.listRowBackground(RoundedRectangle(cornerRadius: 10).fill((node.isSelected) ? Color.selectedColor : Color.clear))
.onTapGesture {
option.selectNode(node)
}
}
var list: some View {
ForEach(node.children!, id:\.name) { child in
NodeView(option: option, node: child, level: level + 1)
}
}
}
extension Color {
static var selectedColor: Color {
#if !os(macOS)
return Color(UIColor.secondarySystemFill)
#endif
#if os(macOS)
return Color(NSColor.selectedColor)
#endif
}
static var systemBlue: Color {
#if !os(macOS)
return Color(UIColor.systemBlue)
#endif
#if os(macOS)
return Color(NSColor.systemBlue)
#endif
}
}
And then just a fixed data model but easy enough to map to Core Data.
import Foundation
class DataProvider: ObservableObject {
@Published var files: [File] = [File]()
@Published var selectedFile: File? = nil {
didSet {
selectedOption = nil
}
}
@Published var selectedOption: String? = nil {
didSet {
selectedItem = nil
}
}
@Published var selectedItem: Node? = nil
@Published var instructionText: String = "Select a project 1"
var selectedMenuOption: MenuOption? {
return selectedFile?.sidebarMenu.first(where: {$0.name == selectedOption})
}
let noFiles = 5
let noMenuOptions = 5
let noItems = 10
init() {
createData()
}
func selectFile(file: File?) {
self.selectedFile?.isSelected = false
self.selectedFile = file
}
func createData(){
for f in 1...noFiles {
let menu = createMenus(f: f)
let file = File(name: "File \(f)", sidebarMenu: menu)
files.append(file)
}
}
func createMenus(f: Int) -> [MenuOption]{
var menuOptions = [MenuOption]()
for m in 1...noMenuOptions {
let nodes = createItems(f: f, m: m)
let menu = MenuOption(name: "Menu Option \(f).\(m)", nodes: nodes)
menuOptions.append(menu)
}
return menuOptions
}
func createItems(f: Int, m: Int) -> [Node] {
var nodes = [Node]()
for i in 1...noItems {
let node = Node(name: "Doc Item \(f).\(m).\(i)", details: "Description for Item \(f).\(m).\(i)")
if ( i % 2) == 0 {
let children = createChildItems(parent: "\(f).\(m).\(i)")
node.children = children
}
nodes.append(node)
}
return nodes
}
func createChildItems(parent: String) -> [Node] {
var nodes = [Node]()
for i in 1...noItems {
let node = Node(name: "Doc Item \(parent).\(i)", details: "Description for Item \(parent).\(i)")
nodes.append(node)
if parent.count < 6 {
if ( i % 2) == 0 {
let children = createChildItems(parent: "\(parent).\(i)")
node.children = children
}
}
}
return nodes
}
}
class File: NSObject, Identifiable, ObservableObject {
let id = UUID()
let name: String
var isSelected: Bool = false
let sidebarMenu: [MenuOption]
var selectedOption: MenuOption?
init (name: String, sidebarMenu: [MenuOption]) {
self.name = name
self.sidebarMenu = sidebarMenu
}
static func == (lhs: File, rhs: File) -> Bool {
return lhs.id == rhs.id
}
}
class MenuOption: NSObject, Identifiable, ObservableObject {
let id = UUID()
let name: String
let nodes: [Node]
@Published var selectedNode: Node?
init(name: String, nodes:[Node]){
self.name = name
self.nodes = nodes
}
static func == (lhs: MenuOption, rhs: MenuOption) -> Bool {
return lhs.id == rhs.id
}
func selectNode(_ node: Node) {
print("Group \(name) item \(node.name) has been selected")
var selec : Node?
for child in nodes {
if let sel = child.selectNode(node) {
selec = sel
}
}
self.selectedNode = selec
}
}
class Node: NSObject, Identifiable, ObservableObject {
let id = UUID()
let name: String
@Published var isExpanded: Bool = false {
didSet {
print("\(name): isExpanded: \(isExpanded)")
}
}
let details : String
@Published var children: [Node]?
@Published var isSelected = false
init(name: String, details:String){
self.name = name
self.details = details
}
static func == (lhs: Node, rhs: Node) -> Bool {
return lhs.id == rhs.id
}
func selectNode(_ node: Node, level: String = "")->Node? {
var sel: Node?
if node.id == self.id {
self.isSelected = true
print(level+"\(name) selected")
sel = self
} else if self.isSelected {
self.isSelected = false
print(level+"\(name) deselected")
}
//print(level+"\(name) is checking children")
if let nodes = self.children {
for child in nodes {
if let selec = child.selectNode(node, level: level + " ") {
sel = selec
}
}
}
return sel
}
}