Building Searchable Tree Views in SwiftUI
SwiftUI's OutlineGroup component provides a convenient way to display hierarchical data structures, similar to what you'd see in a file browser's sidebar. However, when it comes to implementing search functionality that intelligently expands and collapses folders based on search results, OutlineGroup can become quite challenging to work with.
After spending considerable time trying to make OutlineGroup behave properly with search filtering, I decided to build a custom solution from scratch. The result is a flexible, searchable tree view that expands folders containing matching results and collapses irrelevant branches.
Creating the Node Data Model
The foundation of this solution is a simple Node class that represents each item in our tree structure. I initially considered using a struct, but ultimately chose a class to ensure each node can maintain its own expansion state without triggering unnecessary view updates across the entire tree.
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
}
}
The Node class includes an isExpanded
property that tracks whether a folder is currently open. The isFolder
computed property provides a simple way to determine if a node has children, though this approach assumes empty folders don't exist in our data structure.
Implementing the Search Logic with ExplorerStore
The search functionality lives within an ExplorerStore view model that manages the tree data and handles search queries. The key decision here was choosing between depth-first and breadth-first search algorithms.
Understanding Search Strategies
Depth-first search works like drilling - it goes deep into one branch before moving to the next. Breadth-first search is more like raking - it processes all items at the current level before going deeper. For file system searches, depth-first typically provides better user experience as it can quickly find deeply nested items.
The Search Implementation
Here's how the ExplorerStore handles search functionality:
@MainActor
final class ExplorerStore: ObservableObject {
@Published var tree: [Node]
@Published var query = "" {
didSet {
applySearch()
}
}
init(_ tree: [Node]) {
self.tree = tree
}
private func applySearch() {
let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !needle.isEmpty else {
collapseAll(in: tree)
return
}
_ = tree.map { drill(node: $0, needle: needle) }
}
private func drill(node: Node, needle: String) -> Bool {
let hitHere = node.name.lowercased().contains(needle)
var hitInChild = false
for child in node.children {
if drill(node: child, needle: needle) {
hitInChild = true
}
}
node.isExpanded = hitInChild
return hitHere || hitInChild
}
private func collapseAll(in nodes: [Node]) {
for node in nodes {
node.isExpanded = false
collapseAll(in: node.children)
}
}
}
The drill
function recursively searches through the tree structure. When it finds a match in a child node, it ensures the parent remains expanded so users can see the relevant results. Nodes that don't contain matches get collapsed to keep the interface clean.
Building the Recursive UI Components
Instead of wrestling with OutlineGroup's limitations, I created a custom view that renders the tree structure using recursion:
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(!query.isEmpty && node.name.localizedCaseInsensitiveContains(query))
Spacer()
}
.padding(.vertical, 8)
.contentShape(Rectangle())
.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 approach provides several advantages over OutlineGroup. Each node manages its own expansion state, preventing unnecessary re-renders of the entire tree. The recursive structure naturally handles nested folders of any depth, and the search highlighting makes matching text visually prominent.
Key Benefits of This Approach
This custom implementation offers better control over search behavior compared to OutlineGroup. The expansion logic responds intelligently to search queries, automatically revealing relevant content while hiding irrelevant branches. The performance is also improved since only affected nodes update their state rather than triggering complete tree rebuilds.
Summary
This article demonstrates how to build a custom searchable tree view in SwiftUI when the built-in OutlineGroup doesn't meet your requirements. The solution uses a Node class to represent hierarchical data, an ExplorerStore view model to handle search logic with depth-first traversal, and recursive SwiftUI views to render the tree structure. The implementation provides intelligent folder expansion based on search results, making it ideal for file browsers or any hierarchical data that needs search functionality.