00:06 Today, we're going to add search to a macOS app we made called
Workshop. People who follow us on Twitter know we give SwiftUI
workshops, in which we roughly follow
the ideas from our Thinking in SwiftUI book — although the material has
evolved beyond the book.
00:34 Since the pandemic began, we've been running most workshops
remotely, so we needed a way to give attendees the learning material and the
exercises. This led us to build the Workshop app.

00:52 In the app, students find introductions and background information
about various topics, as well as code snippets and interactive examples. Over
time, the app has become packed, so we thought it'd be a good idea to add the
possibility to search through the contents. This will be especially useful after
the workshop, when the app may be used as a reference.
01:41 We'll start by adding the search bar and some placeholder search
results. Later on, we'll see how we can populate the autocompletion with real
results.
Search Field
01:56 In our main content view, which contains the top-level navigation,
we want to add the search functionality. We could call the searchable
modifier
here, but we're going to need a few more things. So to avoid making this file
more complicated than it already is, we decide to immediately call a custom view
modifier, which can wrap the entire search functionality:
public struct Workshop<E: ExerciseList>: View {
var content: some View {
NavigationView {
}
.makeSearchable()
}
}
02:42 We write our custom view modifier in a separate file:
import SwiftUI
extension View {
func makeSearchable() -> some View {
modifier(MakeSearchable())
}
}
fileprivate struct MakeSearchable: ViewModifier {
func body(content: Content) -> some View {
content
}
}
03:29 Inside MakeSearchable
, we call SwiftUI's searchable
modifier
on the content view, passing in a binding for the query:
fileprivate struct MakeSearchable: ViewModifier {
@State private var query: String = ""
func body(content: Content) -> some View {
content
.searchable(text: $query)
}
}
04:17 This adds a search field to the toolbar, but this field doesn't do
anything yet. The next step would be to show a completion popover when the user
types something.
Completions
04:43 When someone searches for the word "frame," we might have results
from many different exercises. We want to show all those occurrences and let the
user decide where they want to jump to.
05:00 It's actually pretty easy to suggest search completions — all we
have to do is call a different overload of searchable
that takes a suggestions
view:
fileprivate struct MakeSearchable: ViewModifier {
@State private var query: String = ""
func body(content: Content) -> some View {
content
.searchable(text: $query) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
}
}
05:27 As soon as we place the cursor in the search field, the three
suggestions pop up. To make these suggestions selectable, we have to provide
search completion for each one:
fileprivate struct MakeSearchable: ViewModifier {
@State private var query: String = ""
func body(content: Content) -> some View {
content
.searchable(text: $query) {
Text("Item 1")
.searchCompletion("Completion 1")
Text("Item 2")
.searchCompletion("Completion 2")
Text("Item 3")
.searchCompletion("Completion 3")
}
}
}
06:03 Now we can use the keyboard or the cursor to select one of the
suggestions. When we do so, the query gets updated and the chosen suggestion is
automatically removed from the popover:

06:39 In practice, we'd probably use a ForEach
to iterate over an
array of completions instead of adding three static views. And rather than
simple Text
s, we can also add more complex views to the suggestions popover:
fileprivate struct MakeSearchable: ViewModifier {
@State private var query: String = ""
func body(content: Content) -> some View {
content
.searchable(text: $query) {
VStack {
Text("Exercise 1").bold()
Text("Item 1")
}
.searchCompletion("Completion 1")
Text("Item 2")
.searchCompletion("Completion 2")
Text("Item 3")
.searchCompletion("Completion 3")
}
}
}
Searchable Content
07:14 Our next challenge is to get access to the content we want to
search. Our app is a little strange in that it doesn't get its contents from a
database or from Markdown files. But the content is written out in SwiftUI views
so that we can interleave text and code views with interactive examples.
07:52 Instead of searching through raw data, we have to propagate the
searchable text up from our views. The upside is that both our Markdown and our
code samples are rendered using the same AttributedText
view. So, we only have
to make this single view searchable.
08:32 The idea is to have the AttributedText
views propagate their
strings up through the preference system and to collect the strings at the top
level. The value we want to propagate up for each search piece should contain
not only the searchable string, but also information about where in the app this
content comes from:
struct SearchPiece {
var string: String
var exercise: Navigation
}
The location of a string in the app is modeled by a struct called Navigation
,
which bundles up an exercise number and a reference to one of the three sections
into which each exercise is divided:
struct Navigation: Hashable, Codable {
var number: Int
var part: ExercisePart
}
enum ExercisePart: String, Hashable, Codable {
case intro
case exercise
case recap
}
10:01 We need a preference key to propagate search pieces up. The
default value for the key is an empty array of SearchPiece
s. In the key's
reduce
function, we combine the contents from various views into a single
array:
enum SearchPieceKey: PreferenceKey {
static var defaultValue: [SearchPiece] = []
static func reduce(value: inout [SearchPiece], nextValue: () -> [SearchPiece]) {
value.append(contentsOf: nextValue())
}
}
10:33 The other thing we need is a method that makes a view searchable
by passing the view's string to the preference system. But before we can set the
preference inside this method, we also need to know to which exercise the view
belongs. This information will have to be passed down into the view somehow:
extension View {
func propagateSearchPiece(_ string: String) -> some View {
self
}
}
11:41 First, we call propagateSearchPiece
inside our text view:
public struct AttributedText: View {
let attributedString: NSAttributedString
public init(_ attributedString: NSAttributedString) {
self.attributedString = attributedString
}
public var body: some View {
AttributedTextRepresentable(attributedString: attributedString)
.propagateSearchPiece(attributedString.string)
}
}
12:16 Within the method, we want to wrap the string in a SearchPiece
,
together with a Navigation
value. Rather than passing a Navigation
down
through every view, it makes more sense to save it in the environment. That way,
each view or view modifier can pick it up as needed:
enum ExerciseKey: EnvironmentKey {
static var defaultValue: Navigation?
}
extension EnvironmentValues {
var exercise: Navigation? {
get { self[ExerciseKey.self] }
set { self[ExerciseKey.self] = newValue }
}
}
13:44 We can't read the environment value in a simple method, so we need
another view modifier. In there, we can use the @Environment
property wrapper
to retrieve the current exercise. By making the view modifiers fileprivate
, we
make sure they don't clutter the global namespace:
extension View {
func propagateSearchPiece(_ string: String) -> some View {
modifier(PropagateSearchPiece(string: string))
}
}
fileprivate struct PropagateSearchPiece: ViewModifier {
var string: String
@Environment(\.exercise) var exercise
func body(content: Content) -> some View {
if let e = exercise {
content.preference(key: SearchPieceKey.self, value: [SearchPiece(string: string, exercise: e)])
} else {
content
}
}
}
15:47 Perhaps we should throw an error if the .exercise
environment
value isn't set, because that would be a programmer error at this point.
Collecting Search Pieces
16:35 Next, we have to collect the search pieces from all exercises. The
only way of doing so — as far as we know — is to render all exercises and read
the preference value in the parent view. This is a bit more work than we can fit
in this episode, so we start by retrieving the content from just the currently
selected exercise. In other words, we'll start by searching on the current page.
17:23 Somewhere in the contents
of the MakeSearchable
view modifier
are the text views that propagate their searchable strings up, so the view
modifier can try to retrieve them from the preferences. To use
onPreferenceChange
, we have to make SearchPiece
conform to Equatable
. Or,
we can even conform to Hashable
, which inherits from Equatable
:
fileprivate struct MakeSearchable: ViewModifier {
@State private var query: String = ""
func body(content: Content) -> some View {
content
.onPreferenceChange(SearchPieceKey.self) { newValue in
print(newValue.count)
}
.searchable(text: $query) {
VStack {
Text("Exercise 1").bold()
Text("Item 1")
}
.searchCompletion("Completion 1")
Text("Item 2")
.searchCompletion("Completion 2")
Text("Item 3")
.searchCompletion("Completion 3")
}
}
}
struct SearchPiece: Hashable {
var string: String
var exercise: Navigation
}
18:03 Running the app, we see 0
printed to the console, meaning we
aren't receiving any search pieces. The problem is that we're never setting the
exercise
environment value. Throwing an error — or at least printing a warning
— wasn't such a bad idea after all:
fileprivate struct PropagateSearchPiece: ViewModifier {
var string: String
@Environment(\.exercise) var exercise
func body(content: Content) -> some View {
if let e = exercise {
content.preference(key: SearchPieceKey.self, value: [SearchPiece(string: string, exercise: e)])
} else {
let _ = print("Warning no exercise")
content
}
}
}
18:40 For now, we hardcode a placeholder value:
fileprivate struct MakeSearchable: ViewModifier {
@State private var query: String = ""
func body(content: Content) -> some View {
content
.environment(\.exercise, Navigation(number: 0, part: .intro)) .onPreferenceChange(SearchPieceKey.self) { newValue in
print(newValue.count)
print(newValue.last)
}
.searchable(text: $query) {
VStack {
Text("Exercise 1").bold()
Text("Item 1")
}
.searchCompletion("Completion 1")
Text("Item 2")
.searchCompletion("Completion 2")
Text("Item 3")
.searchCompletion("Completion 3")
}
}
}
19:13 That's better: we now receive five searchable pieces of content
from the view tree. Next time, we'll see how we can filter those to match the
query and how to display the search results.