How to make SwiftUI List/OutlineGroup lazy for use with large trees like a file system?

1.2k views Asked by At

Here's a simple demo of the hierarchical List in SwiftUI. I'm testing it on macOS Big Sur, but unlike similar tree components in other UI toolkits, it asks for all its children immediately. So I can't use it for something like a file system browser.

Is there a way to make it lazy, so that it only asks for children when the UI element is expanded?

class Thing: Identifiable {
    let id: UUID
    let depth: Int
    let name: String
    init(_ name: String, depth: Int = 0) {
        self.id = UUID()
        self.name = name
        self.depth = depth
    }
    /// Lazy computed property
    var children: [Thing]? {
        if depth >= 5 { return nil }
        if _children == nil {
            print("Computing children property, name=\(name), depth=\(depth)")
            _children = (1...5).map { n in
                Thing("\(name).\(n)", depth:depth+1)
            }
        }
        return _children
    }
    private var _children: [Thing]? = nil
}

struct ContentView: View {
    var things: [Thing] = [Thing("1"), Thing("2"), Thing("3")]
    var body: some View {
        List(things, children: \.children) { thing in
            Text(thing.name)
        }
    }
}

Even though the initial UI only displays the top nodes:

You can see in the console that it asks for everything - all the way down the tree. This is a performance problem for large trees.

...
Computing children property, name=3.4.4.1.4, depth=4
Computing children property, name=3.4.4.1.5, depth=4
Computing children property, name=3.4.4.2, depth=3
Computing children property, name=3.4.4.2.1, depth=4
Computing children property, name=3.4.4.2.2, depth=4
...
1

There are 1 answers

1
Skimble On

I believe this could be a bug in SwiftUI and I hope Apple will fix this. In the meantime, you can use the following workaround:

struct Node {
    var id: String
    var value: String
    var children: [Node]?
}

struct LazyDisclosureGroup: View {
    let node: Node
    @State var isExpanded: Bool = false

    var body: some View {
        if node.children != nil {
            DisclosureGroup(
                isExpanded: $isExpanded,
                content: {
                    if isExpanded {
                        ForEach(node.children!, id: \.self.id) { childNode in
                            LazyDisclosureGroup(node: childNode)
                        }
                    }
                },
                label: { Text(node.value) })
        } else {
            Text(node.value)
        }
    }
}

struct ContentView: View {
    let node = Node(
        id: "a",
        value: "a",
        children: [Node(id: "b", value: "b", children: nil),
                   Node(id: "c", value: "c", children: nil)])
    var body: some View {
        List {
            LazyDisclosureGroup(node: node)
        }
    }
}

I don't know if that's a best practice but the workaround uses the observation that DisclosureGroup "notices" when it is inside a List. The combination of the two produces the same visual structure and behaviour.