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.
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
- Checks whether the current node or any descendant matches the query.
- Opens every ancestor along the way so the hit is visible.
- 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!