简体   繁体   中英

How to preserve expand/collapse state of a SwiftUI List w/ children across app launches?

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:

使用 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.

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

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

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM