If you haven’t met OutlineGroup in SwiftUI yet, picture Finder’s sidebar rendered in SwiftUI: click a chevron, folder children appear. I recently tried to get this working with search. I wanted folders to open and close depending on whether the contained valid results…

After much wrestling and many coffees I gave up on OutlineGroup in favour of a tiny Node model, a ExplorerStore viewmodel, and some recursion.

The result? A searchable file‑browser that behaves at least as my mental model thinks it should.

search-folders-swiftui.gif

The Model: Node

I created a model for the tree-like data structure. I initially created Node as a struct but ultimately I ditched structs for a vintage class so each row owns its own isExpanded flag. That way a toggle on one branch doesn’t trigger a rebuild of the whole forest.

final class Node: ObservableObject, Identifiable, Hashable {
    @Published var isExpanded = false
    let id = UUID()
    let name: String
    let children: [Node]

    var isFolder: Bool { !children.isEmpty }

    init(_ name: String, children: [Node] = []) {
        self.name = name
        self.children = children
    }
}

isExpanded is this only minor complication of this data structure. Otherwise it is a straightforward id, name and children. isFolder acts as a convenient way to check for the presence of children. Although it is a little short-sighted as you could have an empty folder but for my quick concept it is fine.

ViewModel: ExplorerStore

Search lives here. Some quick searching helped me decide that a depth‑first pass would be best.

Depth-First vs Breadth-First

Think drill vs. rake. Depth-First search drills one hole straight down, then moves over. Breadth-First search rakes across the surface row-by-row. The drill is good for finding deeper items but may dig a long wrong hole; the rake guarantees you hit the shallow treasure first.

My Implementation

  1. Checks whether the current node or any descendant matches the query.
  2. Opens every ancestor along the way so the hit is visible.
  3. Collapses anything that doesn’t help.
@MainActor
final class ExplorerStore: ObservableObject {
    // 1. Store the tree as an array of `Node`
    @Published var tree: [Node]

    // 2. Store the search query and run a function when it changes.
    @Published var query = "" {
        didSet { applySearch() }
    }

    init(_ tree: [Node]) { self.tree = tree }

    private func applySearch() {
        // 3. Do some data cleanse before running a search
        let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()

        // 4. Ignore empty searches and close the folders
        guard !needle.isEmpty else {
            collapseAll(in: tree)
            return
        }
        _ = tree.map { drill(node: $0, needle: needle) }
    }

    /// 5. DFS that returns true if `node` or a child matched.
    private func drill(node: Node, needle: String) -> Bool {
        // 6. hitHere means "Yes, this node is relevant"
        let hitHere = node.name.lowercased().contains(needle)
        // 7. hitInChild means "Yes, a child is relevant"
        var hitInChild = false

        // 8. loop through the children
        for child in node.children {
            // 9. as this calls the function itself it is recursive 
            if drill(node: child, needle: needle) { hitInChild = true }
        }

        // 10. if I find something in a child keep the node expanded
        node.isExpanded = hitInChild       

        // 11. the return is passed back to the parent function call on point 9
        return hitHere || hitInChild
    }

    // 12. a recursive call to collapse all children
    private func collapseAll(in nodes: [Node]) {
        for node in nodes {
            node.isExpanded = false
            collapseAll(in: node.children)
        }
    }
}

The UI: Recursive Rows

Rather than fight OutlineGroup, I rendered the tree myself with a little bit of recursion too:

struct NodeRow: View {
    @ObservedObject var node: Node
    let query: String

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            HStack {
                icon
                Text(node.name)
                    // bold any valid text
                    .bold(
                        !query.isEmpty &&
                        node.name.localizedCaseInsensitiveContains(query)
                    )
                Spacer()
            }
            .padding(.vertical, 8)
            .contentShape(Rectangle()) // tap anywhere, not just text
            .onTapGesture { if node.isFolder { node.isExpanded.toggle() } }

            if node.isExpanded {
                ForEach(node.children) { child in
                    NodeRow(node: child, query: query)
                        .padding(.leading, 20)
                }
            }
        }
    }

    @ViewBuilder
    private var icon: some View {
        switch node.isFolder {
        case true where node.isExpanded:
            Image(systemName: "folder.badge.minus")
        case true:
            Image(systemName: "folder.badge.plus")
        case false:
            Image(systemName: "doc")
        }
    }
}

private extension Text {
    func bold(_ condition: Bool) -> some View {
        condition ? self.bold() : self
    }
}

This worked well for my use case as expanding a Node lives inside the Node. No need to rerender the entire tree.

Wrapping Up

That’s it — surprisingly little code for a lot of polish.

The moral of the story: if a built‑in SwiftUI view fights you, sometimes it’s faster to roll your own.

Happy coding!