[英]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 上呈现时的外观:
这种树中的文件夹项目可以折叠和展开。 不幸的是,描述哪些确切的项目被折叠和展开的树 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[] 数组仍将显示披露指示符(用于展开的箭头)。
希望这对某人有所帮助 - 如果需要,我有一个完整的工作演示应用程序。
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.