Swift Talk # 315

Search for a Mac App: Search Field & Completions

This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We add a search field to our SwiftUI workshop app and prepare the content to be searchable.

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 Texts, 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. Instead, 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 SearchPieces. 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 {
        // todo
        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)) // todo
            .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.

Resources

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

136 Episodes · 48h46min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes