繁体   English   中英

如何在应用程序启动时保留带有子项的 SwiftUI 列表的展开/折叠 state?

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

我的视图显示了一个文件系统树,它的当前代码如下所示:

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

以下是在 macOS 上呈现时的外观:

使用 SwiftUI 构建的文件系统树

这种树中的文件夹项目可以折叠和展开。 不幸的是,描述哪些确切的项目被折叠和展开的树 state 不会在应用程序启动时保留。 如何从此List视图中读取此 state 以在应用退出时保留并在应用启动时恢复?

重要提示:我正在寻找List现有展开/折叠 state 的保存和恢复,它控制三角形披露箭头的旋转 state 以及显示或隐藏的每个文件夹项目的子项。 保留和恢复与List children 无关的任何其他 state 和屏幕截图中的披露箭头与此问题无关。

根据用例,这实际上应该使用 SceneStorage 来完成,以便 state 保留每个 window 与整个应用程序(使用用户默认值或 AppStorage)。

您可以通过在列表中为您的 FileItems 创建一个视图来做到这一点。 下面的实现假设 FileItem id 在同一文件的启动之间保持不变,并且在文件之间是唯一的

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

在发现我无法让任何标准 SwiftUI 组件正常工作后,我做了以下事情。

请注意,这适用于 Core Data 以保留扩展的 state 和会话之间的选定节点。

该列表还以无限深度层次结构呈现。

我只包含 NodeView class,它是返回扩展文件夹及其子节点的关键组件,如果子节点为 NIL,则仅返回叶节点。 空的 children[] 数组仍将显示披露指示符(用于展开的箭头)。

希望这对某人有所帮助 - 如果需要,我有一个完整的工作演示应用程序。

这是 iPad 上的样子在此处输入图像描述

或在 Mac 上(催化剂) 在此处输入图像描述

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

然后只是一个固定的数据 model 但足够容易 map 到核心数据。

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

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

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