Adding a Search Bar

In this section, we add another useful feature to our To-Do app: a search bar! The search bar allows the user to quickly find the right task. This is especially useful if the user has already added many to-do’s. 

Creating a custom Search Bar view 🔦

Let’s start by building the interface of our search bar. 

In the end, we want our search bar will look like this:

The background of our tab bar should be a lighter (or darker Gray in dark mode) gray. To do this, we quickly create a custom color set in our Assets folder and name it “LightGray”.

Now, let’s create a new File-New-SwiftUI view file and name it “SearchBar”.

The corresponding SeachBar_Previews should always be exactly the same size as our SearchBar view is. For this, we simply use the .previewLayout modifier and select the .sizeThatFits option. 

struct SearchBar_Previews: PreviewProvider {
     static var previews: some View {
         SearchBar()
             .previewLayout(.sizeThatFits)
     }
 }

Our SearchBar_Previews struct now always minimizes our preview to the size of the initialized SearchBar instance. At the moment this is the frame of the pre-generated Text view.

Basically, our SearchBar should simply consist of a gray background on which we then place an Image view for the “magnifying glass” icon and a TextField. 

For this purpose, we use a ZStack with the .leading alignment mode and set its height to three-quarters of the rowHeight as defined in our TasksView

For the background, we simply use a gray Rectangle.

struct SearchBar: View {
     var body: some View {
         ZStack {
             Rectangle()
                 .foregroundColor(Color("LightGray"))
         }
             .frame(height: rowHeight*0.75)
     }
 }

We now place an HStack on top of the Rectangle. This HStack consists of an Image view with the “magnifyingglass” icon from the SFSymbols app. 

ZStack {
     Rectangle()
         .foregroundColor(Color("LightGray"))
     HStack {
         Image(systemName: "magnifyingglass")
     }
 }
     .frame(height: rowHeight*0.75)

Next to it, we want to place our TextField. As already discussed, we require a property to be bound to the TextField. We will initialize this property later in our TasksView. For our SearchBar_Previews struct, we can simply use a constant value.

import SwiftUI
 

struct SearchBar: View {
     
     @Binding var searchText: String
     
     var body: some View {
         //...
     }
 }
 

 struct SearchBar_Previews: PreviewProvider {
     static var previews: some View {
         SearchBar(searchText: .constant(""))
             .previewLayout(.sizeThatFits)
     }
 }

Now we can initialize our TextField as usual. We also use a .padding to increase the distance between the Image and the TextField and change their .foregroundColor.

HStack {
     Image(systemName: "magnifyingglass")
     TextField("Search task ..", text: $searchText)
 }
     .foregroundColor(.gray)
     .padding(.leading, 13)

Finally, we round off the corners of the entire ZStack and add some .padding to all sides again.

ZStack {
     //...
 }
     .frame(height: rowHeight*0.75)
     .cornerRadius(13)
     .padding()

And this is what our finished SearchBar looks like:

We can now add the SearchBar to our TasksView. But first, we need a corresponding State for the TextField of our SearchBar.

@State var searchText = ""

To make sure the SearchBar is right on top of the existing List, we wrap the List in a VStack.

VStack {
     List {
         //
     }
         .listStyle(GroupedListStyle())
         .navigationTitle("To-Do")
 }

Above the List, we can now insert our SearchBar and bind it to our searchText State:

VStack {
     SearchBar(searchText: $searchText)
     List {
         //...
     }
         .listStyle(GroupedListStyle())
         .navigationTitle("To-Do")
 }

Our finished TasksView now looks like this:

Changing our interface while searching 👨‍💻

Once the user taps on the TextField to start searching, we want to adjust our interface. During the search, we want to change the navigation bar title and show a List of filtered tasks instead of the current List containing all pending tasks.

To do this, we need to be aware of when the user starts the search. For this purpose, we add a corresponding State to our TasksView.

@State var searching = false

We pass this on to our SearchBar as a Binding accordingly. For the SearchBar_Previews we again use a constant value.

import SwiftUI
 

 struct SearchBar: View {
     
     @Binding var searchText: String
     
     @Binding var searching: Bool
     
     var body: some View {
         //...
     }
 }
 

 struct SearchBar_Previews: PreviewProvider {
     static var previews: some View {
         SearchBar(searchText: .constant(""), searching: .constant(false))
             .previewLayout(.sizeThatFits)
     }
 }

Again, we initialize this accordingly in our TasksView.

SearchBar(searchText: $searchText, searching: $searching)

The user starts his search process by tapping the TextField in the SearchBar. We can easily detect this by adding the “onEditingChanged” closure to the TextField. This will be executed as soon as the user starts editing the TextField. As soon as this happens, we change the searching property to true.

TextField("Search task ..", text: $searchText) { startedEditing in
     if startedEditing {
         withAnimation {
             searching = true
         }
     }
 }

Consequently, we want to set searching to false as soon as the user taps the return key of the keyboard. To do this, we append the “onCommit” closure to our TextField:

TextField("Search task ..", text: $searchText) { startedEditing in
     if startedEditing {
         withAnimation {
             searching = true
         }
     }
 } onCommit: {
     withAnimation {
         searching = false
     }
 }

Back to our TasksView. Once the search has started, we want to change the existing .navigationTitle

.navigationTitle(searching ? "Searching" : "To-Do")

We would also like to provide a “Cancel” button during the search process. For this, we use the .toolbar modifier. 

.navigationTitle(searching ? "Searching" : "To-Do")
     .toolbar {
         if searching {
             Button("Cancel") {
                 searchText = ""
                 withAnimation {
                     searching = false
                 }
             }
         }
    }

Let’s see if this works:

As you can see, the keyboard disappears when we tap on the return key (because then the “onCommit” closure of our TextField gets executed). However, the keyboard doesn’t disappear when we tap on the “Cancel” Button in our NavigationBar. 

Resigning the keyboard ⌨️

To fix this, we need to find a way to hide the keyboard manually. There is no native SwiftUI feature for this, so again we have to rely on the UIKit.

Just add the following extension to your TasksView.swift file.

extension UIApplication {
     func dismissKeyboard() {
         sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
     }
 }

The code is quite complicated. Basically, it says that the “control” that currently commands the keyboard should stop commanding it. See this post if you are interested in the topic further.

We can now add the following line to our “Cancel” Button.

Button("Cancel") {
     searchText = ""
     withAnimation {
         //...
         UIApplication.shared.dismissKeyboard()
     }
 }

If we now tap on the “Cancel” Button, the keyboard disappears!

To provide a better user experience, we also want to hide the keyboard while the user is scrolling through the search results.

To do this, we add a .gesture to the List in our TasksView. There are many different gestures available in SwiftUI, for example, the TapGesture or the onLongPressGesture. Here we use a DragGesture.

SearchBar(searchText: $searchText, searching: $searching)
 List {
     //...
 }
     //...
     .gesture(DragGesture()
          
     )

Once the user performs a drag gesture across the List, we want to dismiss the keyboard. For this purpose, we use the .onChanged modifier:

.gesture(DragGesture()
             .onChanged({ _ in
                 UIApplication.shared.dismissKeyboard()
             })
 )

When we run our app now, the keyboard will also be hidden when swiping over the list while performing a search.

Filtering our List’s results 🕵️‍♀️

Okay, we’re almost there. All we need to do now is implement the actual search functionality.

While the user is searching we want to display the search results instead of all tasks pending. For this, we wrap the whole current content of our List into an if-else statement like this:

List {
     if searching {
         
     } else {
         ForEach(fetchedItems, id: \.self) { item in
             //...
         }
             .frame(height: rowHeight)
         HStack {
             //...
         }
             .frame(height: rowHeight)
         NavigationLink(destination: TasksDoneView()) {
             //...
         }
     }
 }

If searching is true, we want to use an alternative ForEach loop that displays only those ToDoItems in our fetchedItems that match the searchText.

if searching {
     ForEach(fetchedItems.filter({ (item: ToDoItem) -> Bool in
         return item.taskTitle!.hasPrefix(searchText) || searchText == ""
     }), id:\.self) { searchResult in
         HStack {
             Text(searchResult.taskTitle ?? "Empty")
             Spacer()
             Button(action: {
                 markTaskAsDone(at: fetchedItems.firstIndex(of: searchResult)!)
             }){
                 Image(systemName: "circle")
                     .imageScale(.large)
                     .foregroundColor(.gray)
             }
         }
     }
         .frame(height: rowHeight)
 }

Using the filter function we cycle through each ToDoItem in the fetchedItems. Only if the particular item has the same initial letters as the searchText (or if there is no searchText at all), we use it for our alternative ForEach loop.

We can now run our app and see if our search bar works!

Conclusion 🎊

Wow, we’ve learned a lot so far!

We discovered how the CoreData framework interacts with SwiftUI and how we can fetch and save objects using our own data model.

We learned what the viewContext is, how it gets initialized by the context property of the PersistenceController, and how we can access it using the @Environment property wrapper and .environment modifier. We also learned how to filter to-do’s by using the “predicate” argument of the @FetchRequest property wrapper.

We saw how easy it is to delete to-do items using the delete method of the viewContext combined with the .onDelete modifier of the ForEach loop.

Finally, we learned how to let the user filter his tasks by providing a search bar.

You are now able to store data persistently by using the CoreData framework within your SwiftUI projects!

Leave a Reply

Your email address will not be published. Required fields are marked *