4

My view displays a filesystem tree and currently code for it looks like this:

struct FileItem: Hashable, Identifiable, CustomStringConvertible {
  var id: Self { self }
  var name: String
  var children: [FileItem]?

  var description: String {
    switch children {
    case nil:
      return " \(name)"
    case let .some(children):
      return children.isEmpty ? " \(name)" : " \(name)"
    }
  }
}

struct FileTree: View {
  let root: FileItem
  var body: some View {
    List(
      [root],
      children: \.children
    ) { item in
      HStack {
        Text(item.description)
        Spacer()
      }
      .padding(2)
    }
    .listStyle(SidebarListStyle())
  }
}

And here's how it looks when rendered on macOS:

a filesystem tree built with SwiftUI

Folder items in such tree can be collapsed and expanded. Unfortunately, the tree state describing which exact items are collapsed and expanded is not preserved across app launches. How can this state be read from this List view to be preserved when the app quits and restored when the app launches?

IMPORTANT: I'm looking for preservation and restoration of the existing expand/collapse state of the List, which controls the rotation state of the triangular disclosure arrow, and children of every folder item that are shown or hidden. Preserving and restoring any other state not related to the List children and the disclosure arrows as on the screenshot is not relevant to this question.

Max Desiatov
  • 5,087
  • 3
  • 48
  • 56
  • 3
    You need to do that by yourself, storing for example expanded paths in UserDefaults and handling in view programmatically using DisclosureGroup (eg. for DisclosureGroup is in https://stackoverflow.com/a/63228810/12299030) – Asperi Jan 10 '21 at 13:06

2 Answers2

0

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 enter image description here

Or on a Mac (catalyst) enter image description here

and macOS enter image description here

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
            
    }
}
Duncan Groenewald
  • 8,496
  • 6
  • 41
  • 76
-1

Depending on the use case, this should really be done using SceneStorage so that the state is preserved per window vs. throughout the whole application (using user defaults or AppStorage).

You can do that by creating a view for your FileItems in the list. The below implementation assumes the FileItem id persists between launches for the same file and is unique across files

struct FileItemView: View {
    let item: FileItem
    let expanded: SceneStorage<Bool>
    init(_ item: FileItem) {
        self.item = item
        self.expanded = SceneStorage(wrappedValue: false, "FileItemView.expanded.\(item.id)")
    }
    
    var body: some View {
        HStack {
            Text(item.description)
            Button(expanded.wrappedValue ? "expanded" : "collapsed") {
                expanded.wrappedValue.toggle()
            }
            Spacer()
        }
        .padding(2)
    }
}

struct FileTree: View {
  let root: FileItem
  var body: some View {
    List(
      [root],
      children: \.children
    ) { item in
      FileItemView(item)
    }
    .listStyle(SidebarListStyle())
  }
}
JuJoDi
  • 14,627
  • 23
  • 80
  • 126
  • In your example code children of a folder item aren't hidden or shown when it's collapsed or expanded. You've introduced an additional `Button` here, which doesn't remove or add children when toggled, while in the original example shown in the question the standard disclosure button does it. – Max Desiatov Jan 11 '21 at 19:12
  • @MaxDesiatov I just did that as an example so you could see that the value can be changed and is stored and retrieved through scene launches. Question was about preserving state – JuJoDi Jan 11 '21 at 19:18
  • The question is specifically about preserving state of `List` disclosure items and expanded/collapsed children. Your answer doesn't clarify how to preserve children expand/collapse state. – Max Desiatov Jan 12 '21 at 21:54
  • I might be confused then. My answer preserves the expanded/collapsed state of each FileItem in the list specific to a scene and is recreated properly when the scene is reloaded. I'm not sure how that's different from what you're asking. – JuJoDi Jan 12 '21 at 22:58
  • "Expanded" or "collapsed" means that children of a given item are respectively shown or hidden and that the disclosure triangle arrow changes its rotation state. Code in your answer does neither, it doesn't show/hide children of an item in expanded/collapsed state, and it doesn't rotate the disclosure arrow. The only thing it changes is text of a button, which wasn't present in the original question code. – Max Desiatov Jan 13 '21 at 11:54
  • The question reads "How can this state be read from this List view to be preserved when the app quits and restored when the app launches?". The button is there as an example to change the state so you can see it preserved between launches, not to actually expand and collapse anything. – JuJoDi Jan 13 '21 at 12:17
  • Please have a look at the title of the question, which explicitly says "How to preserve _expand/collapse state_ of a SwiftUI List w/ children". Also, check out the question body, which says "Unfortunately, the tree state describing _which exact items are collapsed and expanded_ is not preserved across app launches. How can this state be read from this List view [...]". I am explicitly asking about expand/collapse of the `List` view _with children_ in the example code I gave, not some arbitrary state of an arbitrary unrelated button. – Max Desiatov Jan 13 '21 at 13:46