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

Viewed 667

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
...
2 Answers

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.

I created a Swift package to address this here: https://github.com/EncodiaDotCo/OutlineView.

It's not pretty yet but it lazy loads children. If Apple doesn't fix this, I'll work on improving the package. Bug reports and feature requests are welcome.

Related