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:
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
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.