Home iOS & Swift Books Real-World iOS by Tutorials

6
Building Features - Search Written by Renan Benatti Dias

In the previous chapter, you learned how to create a list of animals with infinite scrolling using SwiftUI. You also learned about view models and how to decouple your view code from domain code.

In this chapter, you’ll build a search feature for people that want to search Petfinder’s API for a pet better suited to them.

More specifically, you’ll learn how to:

  • Use a new view modifier, introduced in iOS 15, to add a search bar to your view.

  • Filter pets by name, age and type.

  • Search animals on an external web service, Petfinder’s API.

  • Use Form and Picker views to create a filter view.

  • Improve the UI to make it more approachable.

You’ll also learn to leverage @ViewBuilders to reuse view code from Animals Near You inside Search.

Building Search

Search is a feature that many apps have. While it’s nice to scroll through a list of animals to find a pet you like, you might be scrolling through hundreds of animals.

This might get tedious when you have a large collection of data. If a user is looking to adopt an animal of a specific age or type, they should be able to search for an animal that way.

Right now, you can scroll through animals in Animals Near You, but you can’t do anything in Search. It’s just a blank screen.

You’ll build a search view with a search bar and a filter for age and type to better filter results.

You’ll start by listing all animals already stored. Then, you’ll add a search bar so users can type a name and filter the results. Finally, you’ll add a form for people to pick their preferred age and type.

At the end of this chapter, Search is going to look like this:

Search screen showing search bar and browse by type
Search screen showing search bar and browse by type

Building the base UI

Open SearchView.swift and add this declaration at the top:

@FetchRequest(
  sortDescriptors: [
    NSSortDescriptor(
      keyPath: \AnimalEntity.timestamp, ascending: true)
  ],
  animation: .default
)
private var animals: FetchedResults<AnimalEntity>

This code adds a property, animals, that’s a FetchedResults of the current animals stored in the database. It’s sorted by their timestamp. You’ll filter these results by typing a name on the search bar. This gets data from Core Data thanks to @FetchRequest.

Next, replace the code inside NavigationView with:

List {
  ForEach(animals) { animal in
    NavigationLink(destination: AnimalDetailsView()) {
      AnimalRow(animal: animal)
    }
  }
}
.listStyle(.plain)
.navigationTitle("Find your future pet")

This code replaces the blank view and creates a list with animals. Build and run. Then click the Search tab.

Search screen showing a list of animals
Search screen showing a list of animals

This looks a lot like Animals Near You. It reuses the same row view you created in Chapter 5, “Building Features - Locating Animals Near You”, and the code used to create the list is identical to the one in Animals Near You.

Before you add a search bar and start filtering animals, you’ll create a new view to share this code in both features.

Extracting Animal List view

The only difference between the code you just added for SearchView and the code for AnimalsNearYouView is that the list inside AnimalsNearYouView has a view at the bottom for loading more animals. The rest is pretty much the same. Both views use a List to place each animal in a row, and both rows take to the same AnimalDetailsView.

SwiftUI is great for creating views you can reuse in other views. Keeping both views as the same component has a couple of benefits:

  1. You ensure both views behave the same way. They’re both Lists of animals.

  2. If you need to change the look of the list or the rows, you don’t have to write code twice.

To avoid code repetition, you’ll create a custom list view to use in both features, Animals Near You and Search.

Using @ViewBuilders to build custom views

The main difference between the Lists inside AnimalsNearYouView and SearchView is the ProgressViewat the end. To create a view that takes any view at the bottom, or anywhere else, you’ll have to use a SwiftUI feature, View Builders.

@ViewBuilder is a result builder introduced for SwiftUI. It uses result builders to create a DSL-like syntax for composing views from a closure. It lets you declare your views, one after the other, inside the body property.

Note: You can also use @resultBuilder to compose other types. @ViewBuilder is just a specialization of a result builder for views.

You can also use @ViewBuilder to create custom views that encapsulate other views, like the VStack, HStack and List.

You’ll use this to create a custom Animal List View to share code between Animals Near You and Search.

Inside Core create a group called views, then inside views create a new SwiftUI View and name it AnimalListView.swift. Replace the contents of the file with:

import SwiftUI

// 1
struct AnimalListView<Content, Data>: View
  where Content: View,
  Data: RandomAccessCollection,
  Data.Element: AnimalEntity {
  let animals: Data

  // 2
  let footer: Content

  // 3
  init(animals: Data, @ViewBuilder footer: () -> Content) {
    self.animals = animals
    self.footer = footer()
  }

  // 4
  init(animals: Data) where Content == EmptyView {
    self.init(animals: animals) {
      EmptyView()
    }
  }

  var body: some View {
    // 5
    List {
      ForEach(animals) { animal in
        NavigationLink(destination: AnimalDetailsView()) {
          AnimalRow(animal: animal)
        }
      }

      // 6
      footer
    }
    .listStyle(.plain)
  }
}

struct AnimalListView_Previews: PreviewProvider {
  static var previews: some View {
    NavigationView {
      AnimalListView(animals: CoreDataHelper.getTestAnimalEntities() ?? [])
    }

    NavigationView {
      AnimalListView(animals: []) {
        Text("This is a footer")
      }
    }
  }
}

Here’s a breakdown of AnimalListView:

  1. It defines a new view with the generic parameters Content and Data. Then, it defines constraints to those types, Content being a View, Data a RandomAccessCollection and Data.Element an AnimalEntity. Now, you can use AnimalListView with any type of collection, as long as it’s a collection of AnimalEntity.

  2. A property for holding the list’s footer view.

  3. An initializer that takes an array of animal entities and a view builder closure for the footer view.

  4. A second initializer that takes only an array of animal entities. This initializer uses an empty view for the list’s footer.

  5. The body of the view, laying down a list with rows of animals.

  6. The footer view passed in the initializer, placed at the bottom of the list.

Back inside SearchView.swift, find:

List {
  ForEach(animals) { animal in
    NavigationLink(destination: AnimalDetailsView()) {
      AnimalRow(animal: animal)
    }
  }
}
.listStyle(.plain)

And replace it with:

AnimalListView(animals: animals)

Next, inside AnimalsNearYouView.swift, find:

List {
  ForEach(animals) { animal in
    NavigationLink(destination: AnimalDetailsView()) {
      AnimalRow(animal: animal)
    }
  }
  if !animals.isEmpty && viewModel.hasMoreAnimals {
    ProgressView("Finding more animals...")
      .padding()
      .frame(maxWidth: .infinity)
      .task {
        await viewModel.fetchMoreAnimals()
      }
  }
}

Replace it with:

AnimalListView(animals: animals) {
  if !animals.isEmpty && viewModel.hasMoreAnimals {
    ProgressView("Finding more animals...")
      .padding()
      .frame(maxWidth: .infinity)
      .task {
        await viewModel.fetchMoreAnimals()
      }
  }
}

Build and run to make sure everything still works like before.

Near you screen showing a list of animals
Near you screen showing a list of animals

Now, it’s time to add a search bar for searching animals by name.

Filtering locally

Open SearchView.swift and add this to SearchView:

@State var searchText = ""

This line adds a new @State variable that keeps track of the text the user types.

Next, add the following modifier at the end of AnimalListView:

.searchable(
  text: $searchText,
  placement: .navigationBarDrawer(displayMode: .always)
)

This will add a search bar to you view. Build and run.

Tap Search and you’ll something like this:

Search screen with a search bar at the top
Search screen with a search bar at the top

The new searchable Modifier

searchable(text:placement:prompt:), is a new view modifier added in iOS 15, adds a search bar to a NavigationView. You pass a Binding to a String and the placement of the search bar. The search bar then updates the Binding when the user types.

Still in SearchView.swift, add the following computed property:

var filteredAnimals: [AnimalEntity] {
  animals.filter {
    if searchText.isEmpty {
      return true
    }
    return $0.name?.contains(searchText) ?? false
  }
}

You use the computed property filteredAnimals to filter animals from core data with searchText. If searchText is empty, you return all the animals from core data. Otherwise, you match their name with the text the user typed.

Next, replace:

AnimalListView(animals: animals)

With:

AnimalListView(animals: filteredAnimals)

This code replaces AnimalListView’s data source with your new property.

Build and run. Type the name of a pet in the search bar to see the result.

Search screen with results of searching by name
Search screen with results of searching by name

Success! SwiftUI filters the results from Core Data with the name you type.

However, you’re only filtering locally stored animals, not the entire Petfinder’s API database.

You’ll add a request to search the API now.

Searching Petfinder’s API

You’re going to search Petfinder’s API and add network logic to this feature. To avoid making SearchView even bigger and adding domain logic to view code, create a new view model to handle this.

Creating a View Model for searching

Create a new group inside Search and name it viewModels. Next, create a new file inside the new group and name it SearchViewModel.swift.

Add the following code to the new file:

final class SearchViewModel: ObservableObject {
  @Published var searchText = ""

  var shouldFilter: Bool {
    !searchText.isEmpty
  }
}

You declare a published variable called searchText that will contain the text typed by the user. You also use shouldFilter later to add and remove views if the search bar is empty.

Now, you’ll create a new service for searching the API with a query and other filters you’ll add later, like age and type.

Creating the service for searching

Still inside SearchViewModel.swift, add the following protocol at the top of the file:

protocol AnimalSearcher {
  func searchAnimal(
    by text: String,
    age: AnimalSearchAge,
    type: AnimalSearchType
  ) async -> [Animal]
}

This protocol defines a service that searches for animals by text, age and type.

Next, inside the Search group, create a new group and name it services. Create a file and name it AnimalSearcherService.swift. Add the following code to this new file:

// 1
struct AnimalSearcherService {
  let requestManager: RequestManagerProtocol
}

// MARK: - AnimalSearcher
// 2
extension AnimalSearcherService: AnimalSearcher {
  func searchAnimal(
    by text: String,
    age: AnimalSearchAge,
    type: AnimalSearchType
  ) async -> [Animal] {
    // 3
    let requestData = AnimalsRequest.getAnimalsBy(
      name: text,
      age: age != .none ? age.rawValue : nil,
      type: type != .none ? type.rawValue : nil
    )
    // 4
    do {
      let animalsContainer: AnimalsContainer = try await requestManager
        .perform(requestData)
      return animalsContainer.animals
    } catch {
      // 5
      print(error.localizedDescription)
      return []
    }
  }
}

Here’s a breakdown of the struct you just added:

  1. Here, you declare a new service, AnimalSearcherService, with a request manager to make requests to Petfinder’s API.

  2. Then, you extend AnimalSearcherService to conform to AnimalSearcher.

  3. You create requestData passing the text, age and type. If age or type are not selected, you don’t pass those values in the request.

  4. Here, you make a request with the given data and return an array of animals.

  5. If an error happens, you print the error the request thrown and return an empty array.

Next, back in SearchViewModel.swift, add this to SearchViewModel:

private let animalSearcher: AnimalSearcher
private let animalStore: AnimalStore

init(animalSearcher: AnimalSearcher, animalStore: AnimalStore) {
  self.animalSearcher = animalSearcher
  self.animalStore = animalStore
}

This adds two properties to your view model: animalSearcher to search animals and animalStore for storing the results in the database. You also add an initializer for injecting those properties.

Also, add:

func search() {
  Task {
    // 1
    let animals = await animalSearcher.searchAnimal(
      by: searchText,
      age: .none,
      type: .none
    )

    // 2
    do {
      try await animalStore.save(animals: animals)
    } catch {
      print("Error storing animals... \(error.localizedDescription)")
    }
  }
}

Here’s what’s happening:

  1. You start a request passing the text the user typed as a parameter. For now, you pass none for age and type. You’ll add those filters later.

  2. Save the results in Core Data and handle an error it may throw.

When typing on the search bar, you use this method for querying the API and saving results. Then, Core Data also updates the results and displays them on screen.

Now that your view model is complete, it’s time to update the view to use it.

Refactoring the view to use the view model

Inside SearchView.swift, replace searchText declaration with:

@StateObject var viewModel = SearchViewModel(
  animalSearcher: AnimalSearcherService(requestManager: RequestManager()),
  animalStore: AnimalStoreService(
    context: PersistenceController.shared.container.newBackgroundContext()
  )
)

Here, you add a StateObject variable for the view model you just created. @StateObject makes this an observable object so your views can observe and react to its changes.

Next, find filteredAnimals, and replace its code with:

guard viewModel.shouldFilter else { return [] }
return animals.filter {
  if viewModel.searchText.isEmpty {
    return true
  }
  return $0.name?.contains(viewModel.searchText) ?? false
}

This code updates filteredAnimals to use your new view model to filter animals using the new searchText property. It returns an empty array if shouldFilter is true.

Finally, find the text parameter of searchable(text:placement:):

text: $searchText,

And replace it with:

text: $viewModel.searchText,

This code binds searchText from your view model to the view’s search bar.

Build and run to make sure everything still works.

Search screen with results of searching by name
Search screen with results of searching by name

Your view uses your view model to search locally. It’s time to add a call to also search Petfinder’s API.

Searching the API

Still in SearchView.swift, under searchable(text:placement:), add:

// 1
.onChange(of: viewModel.searchText) { _ in
  // 2
  viewModel.search()
}

This is what’s happening:

  1. onChange(of:perform:) is a modifier that observes changes to a type that conforms to Equatable, in this case viewModel.searchText.

  2. It then calls a closure with a new value whenever it changes, you put viewModel.search() so it gets called when the user types on the search bar.

Build and run. Type a name that isn’t on the list.

Search screen with results of searching by name
Search screen with results of searching by name

At first, the app may not have the animal stored locally, but as the request to the API completes, the results are added to the list.

Finally, you’ll fix Xcode previews for SearchView.

Updating previews with mock data

Inside Search/services, create a new file and name it AnimalSearcherMock.swift. Add the following code:

struct AnimalSearcherMock: AnimalSearcher {
  func searchAnimal(
    by text: String,
    age: AnimalSearchAge,
    type: AnimalSearchType
  ) async -> [Animal] {
    var animals = Animal.mock
    if age != .none {
      animals = animals.filter {
        $0.age.rawValue.lowercased() == age.rawValue.lowercased()
      }
    }
    if type != .none {
      animals = animals.filter {
        $0.type.lowercased() == type.rawValue.lowercased()
      }
    }
    return animals.filter { $0.name.contains(text) }
  }
}

This creates a new mock object conforming to AnimalSearcher that mocks the result from the API for Xcode previews.

Back inside SearchView.swift, in the preview code at the bottom of the file, replace:

SearchView()

With:

SearchView(
  viewModel: SearchViewModel(
    animalSearcher: AnimalSearcherMock(),
    animalStore: AnimalStoreService(
      context: PersistenceController.preview.container.viewContext
    )
  )
)
.environment(
  \.managedObjectContext,
  PersistenceController.preview.container.viewContext
)

This code adds a view model with your mock service for displaying a list of animals in Xcode previews.

Resume the preview by clicking resume at the top right corner of the preview canvas or use Command-Option-P. Activate live preview and type something in the search bar.

Search screen in live preview
Search screen in live preview

Handling empty results

Everything is working great so far, but if you search for an animal Petfinder’s API doesn’t have, the app simply shows a blank screen.

You’ll fix that by adding a message explaining the app didn’t find any results.

In SearchView.swift, at the bottom of AnimalListView, add the following modifier:

.overlay {
  if filteredAnimals.isEmpty && !viewModel.searchText.isEmpty {
    EmptyResultsView(query: viewModel.searchText)
  }
}

This code adds a new overlay with EmptyResultsView, a view in this chapter’s starter project. This view displays a message explaining that the app didn’t find any animals with that name. It only appears onscreen when the filtered results and search bar are empty.

Build and run. Search for a name that isn’t on the list.

Search screen showing a message when there aren't results available
Search screen showing a message when there aren't results available

Filtering animals by age and type

Now that you can filter animals by name, it’s time to filter them by age and type to help users find suitable pets faster.

Adding age and type to the view model

Open SearchViewModel.swift and add these two published properties to SearchViewModel:

@Published var ageSelection = AnimalSearchAge.none
@Published var typeSelection = AnimalSearchType.none

You’ll use them to keep track of the age and type the user selected. They both start with none, in case the user doesn’t want to use any filters.

Next, update shouldFilter with:

!searchText.isEmpty ||
  ageSelection != .none ||
  typeSelection != .none

This code updates this property to take into account the age and type the user selects.

Next, add the following method at the end of the class:

func clearFilters() {
  typeSelection = .none
  ageSelection = .none
}

This method sets the selection of typeSelection and ageSelection back to none.

Inside search(), find and update the following two lines:

age: .none,
type: .none

With:

age: ageSelection,
type: typeSelection

Now, when the user searches an animal, it also sends the type and age that the user picked.

You’re done updating the view model. It’s time to create a view for users to select the animal’s age and type.

Building a picker view

To filter animals by age and type, you’ll build a form view that lets you pick from the available options.

Inside Search/views, create a new SwiftUI View and name it SearchFilterView.swift.

Add the following properties:

@Environment(\.dismiss) private var dismiss
@ObservedObject var viewModel: SearchViewModel

dismiss is an environment value you access to dismiss the current presentation. You’ll use it to dismiss SearchFilterView when the user finishes picking their preference.

You also added viewModel, which will contain a reference to your SearchViewModel.

Next, replace the contents of body with:

Form {
  Section {
    // 1
    Picker("Age", selection: $viewModel.ageSelection) {
      ForEach(AnimalSearchAge.allCases, id: \.self) { age in
        Text(age.rawValue.capitalized)
      }
    }
    // 2
    .onChange(of: viewModel.ageSelection) { _ in
      viewModel.search()
    }

    // 3
    Picker("Type", selection: $viewModel.typeSelection) {
      ForEach(AnimalSearchType.allCases, id: \.self) { type in
        Text(type.rawValue.capitalized)
      }
    }
    // 4
    .onChange(of: viewModel.typeSelection) { _ in
      viewModel.search()
    }
  } footer: {
    Text("You can mix both, age and type, to make a more accurate search.")
  }

  // 5
  Button("Clear", role: .destructive, action: viewModel.clearFilters)
  Button("Done") {
    dismiss()
  }
}
.navigationBarTitle("Filters")
.toolbar {
  // 6
  ToolbarItem {
    Button {
      dismiss()
    } label: {
      Label("Close", systemImage: "xmark.circle.fill")
    }
  }
}

Here’s what this code builds:

  1. First, it adds a Picker view for selecting the pet’s age. This value can either be baby, young, adult or senior which are the cases for AnimalSearchAge.

  2. Then it adds an onChange(of:perform:) view modifier that triggers a call to viewModel.search when an age is selected.

  3. It adds another Picker view for selecting the pet’s type. This value can be cat, dog, rabbit, smallAndFurry, horse, bird, scalesFinsAndOther or barnyard, which are the cases for AnimalSearchType.

  4. Then it adds an onChange(of:perform:) view modifier that also triggers a call to viewModel.search, but this time, with the selected type.

  5. It adds two buttons, one for clearing both filters and another for dismissing the view.

  6. Finally, it adds a toolbar button for dismissing the view.

You’ll use this Form view to select the animal’s age and type. Before you do that, you’ll fix Xcode previews for this form.

At the bottom of the file, inside SearchFilterView_Previews, update previews with:

let context = PersistenceController.preview.container.viewContext
NavigationView {
  SearchFilterView(
    viewModel: SearchViewModel(
      animalSearcher: AnimalSearcherMock(),
      animalStore: AnimalStoreService(context: context)
    )
  )
}

This code adds a view model to the preview with a mocked service, so you can render this form in Xcode previews.

Run Xcode previews by pressing Command-Option-P.

Search filter view
Search filter view

Now that you’re done with SearchFilterView, you have to add a button for presenting this new form.

Adding a button to open SearchFilterView

Back inside SearchView.swift, add:

@State var filterPickerIsPresented = false

You’ll use this property to present SearchFilterView.

Next, below the overlay(alignment:content:) at the bottom of AnimalListView, add:

// 1
.toolbar {
  ToolbarItem {
    Button {
      filterPickerIsPresented.toggle()
    } label: {
      Label("Filter", systemImage: "slider.horizontal.3")
    }
    // 2
    .sheet(isPresented: $filterPickerIsPresented) {
      NavigationView {
        SearchFilterView(viewModel: viewModel)
      }
    }
  }
}

This code:

  1. Adds a new button in the top right corner of the toolbar.
  2. It presents SearchFilterView inside a NavigationView using sheet(isPresented:onDismiss:content:). The modal will appear when filterPickerIsPresented is true.

Build and run. Open the new filter view and select an age and type.

Filter view on top of Search View
Filter view on top of Search View

Even though you can select an age and a type, the app doesn’t yet filter the results. To fix this, you’ll use a feature added to Swift 5.2, callAsFunction.

Using callAsFunction to filter animals

callAsFunction is a Swift feature that lets you call types as if they were functions. The type implements a method called callAsFunction. When you call the type, it forwards the call to this method.

callAsFunction is a nice feature for types that behave like functions.

You’ll create a type to filter animals with the text from the search bar and the age and type selected.

Create a new file inside Search/viewModels and name it FilterAnimals.swift. Add the following code to this file:

import SwiftUI

// 1
struct FilterAnimals {
  // 2
  let animals: FetchedResults<AnimalEntity>
  let query: String
  let age: AnimalSearchAge
  let type: AnimalSearchType

  // 3
  func callAsFunction() -> [AnimalEntity] {
    let ageText = age.rawValue.lowercased()
    let typeText = type.rawValue.lowercased()
    // 4
    return animals.filter {
      if ageText != "none" {
        return $0.age.rawValue.lowercased() == ageText
      }
      return true
    }
    .filter {
      if typeText != "none" {
        return $0.type?.lowercased() == typeText
      }
      return true
    }
    .filter {
      if query.isEmpty {
        return true
      }
      return $0.name?.contains(query) ?? false
    }
  }
}

Here you:

  1. Declare a regular struct and name it FilterAnimals.

  2. Declare properties for the animals you want to filter, the query from the search bar and the age and type selected.

  3. Implement a method called callAsFunction that Swift forwards whenever you call this type like a function.

  4. Chain filter(_:) calls to filter animals by name, age and type. First by age, then by type and finally by name.

Now, inside SearchView.swift, add:

private var filterAnimals: FilterAnimals {
  FilterAnimals(
    animals: animals,
    query: viewModel.searchText,
    age: viewModel.ageSelection,
    type: viewModel.typeSelection
  )
}

This creates a new computed property that creates a new FilterAnimals instance with the current animals displayed, text from the search bar and age and type selection.

Next, replace the content of filteredAnimals with:

guard viewModel.shouldFilter else { return [] }
return filterAnimals()

Now, filteredAnimals use the new instance of FilterAnimals to filter them by name, age and type.

Build and run. Filter by name, age and type to see the results.

Search view with results after filtering
Search view with results after filtering

Improving the UI

The search feature is done. And yet, this view feels a bit too blank. When the user isn’t filtering, the view has no content to show.

You’ll add a suggestions view for showing the possible types of animals users can search for.

Open SearchViewModel.swift and inside SearchViewModel, add:

func selectTypeSuggestion(_ type: AnimalSearchType) {
  typeSelection = type
  search()
}

This method sets typeSelection to a selected type and triggers a search to the API.

Back inside SearchView.swift, add another overlay right below the List’s toolbar modifier with:

.overlay {
  // 1
  if filteredAnimals.isEmpty && viewModel.searchText.isEmpty {
    // 2
    SuggestionsGrid(suggestions: AnimalSearchType.suggestions) { suggestion in
      // 3
      viewModel.selectTypeSuggestion(suggestion)
    }
    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
  }
}

This code:

  1. Adds a new overlay to the view that appears when filteredAnimals is empty, and there isn’t any text in the search bar, nor any age and type selected.
  2. Creates a SuggestionsGrid. This is a view contained in this chapter’s starter project that shows a grid of animal types users can tap to filter.
  3. If a suggestion is selected, you call selectTypeSuggestion(_:) to update the view model and fire a search.

Build and run to see the suggestions grid on the search view.

Search screen showing search bar and browse by type
Search screen showing search bar and browse by type

Testing your view model

To close this chapter, you’ll write unit tests for your filters.

Setting up test cases

Inside PetSaveTests/Tests, create a new group and name it Search.

Inside Search, create a new Swift file and name it SearchViewModelTestCase.swift. Add:

import Foundation
import XCTest
@testable import PetSave

final class SearchViewModelTestCase: XCTestCase {
  let testContext = PersistenceController.preview.container.viewContext
  // swiftlint:disable:next implicitly_unwrapped_optional
  var viewModel: SearchViewModel!

  override func setUp() {
    super.setUp()
    viewModel = SearchViewModel(
      animalSearcher: AnimalSearcherMock(),
      animalStore: AnimalStoreService(context: testContext)
    )
  }
}

This code creates a new test case for testing SearchViewModel. It set’s up the view model with an AnimalSearcherMock and an AnimalStoreService with an in-memory context, so each test has mock animals and an empty database to run.

You’ll start by testing shouldFilter and how searchText, ageSelection and typeSelection affect this computed property.

Testing shouldFilter

Still inside SearchViewModelTestCase, add:

func testShouldFilterIsFalseForEmptyFilters() {
  XCTAssertTrue(viewModel.searchText.isEmpty)
  XCTAssertEqual(viewModel.ageSelection, .none)
  XCTAssertEqual(viewModel.typeSelection, .none)
  XCTAssertFalse(viewModel.shouldFilter)
}

SearchViewModel starts with all properties with empty values. searchText is an empty String and ageSelection and typeSelection are .none. That means the user just opened the view and didn’t search for anything. In this test case, you expect shouldFilter to be false.

Build and run the test by clicking the diamond play button at the side of the test function.

Note: You can also run all tests of a test case by clicking the diamond play button at the side of the class declaration.

Filter test passed
Filter test passed

Awesome! Next, you’ll test if changing any of the three properties is enough to change shouldFilter to true.

Add the following three methods:

func testShouldFilterIsTrueForSearchText() {
  viewModel.searchText = "Kiki"
  XCTAssertFalse(viewModel.searchText.isEmpty)
  XCTAssertEqual(viewModel.ageSelection, .none)
  XCTAssertEqual(viewModel.typeSelection, .none)
  XCTAssertTrue(viewModel.shouldFilter)
}

func testShouldFilterIsTrueForAgeFilter() {
  viewModel.ageSelection = .baby
  XCTAssertTrue(viewModel.searchText.isEmpty)
  XCTAssertEqual(viewModel.ageSelection, .baby)
  XCTAssertEqual(viewModel.typeSelection, .none)
  XCTAssertTrue(viewModel.shouldFilter)
}

func testShouldFilterIsTrueForTypeFilter() {
  viewModel.typeSelection = .cat
  XCTAssertTrue(viewModel.searchText.isEmpty)
  XCTAssertEqual(viewModel.ageSelection, .none)
  XCTAssertEqual(viewModel.typeSelection, .cat)
  XCTAssertTrue(viewModel.shouldFilter)
}

Build and test again.

Should filters tests passed
Should filters tests passed

Testing clearing filters

Next, you’ll test if clearing the filters also changes shouldFilter when the search text is empty and when it’s not.

Add the following two test methods, right below the previous ones:

func testClearFiltersSearchTextIsNotEmpty() {
  viewModel.typeSelection = .cat
  viewModel.ageSelection = .baby
  viewModel.searchText = "Kiki"

  viewModel.clearFilters()

  XCTAssertFalse(viewModel.searchText.isEmpty)
  XCTAssertEqual(viewModel.ageSelection, .none)
  XCTAssertEqual(viewModel.typeSelection, .none)
  XCTAssertTrue(viewModel.shouldFilter)
}

func testClearFiltersSearchTextIsEmpty() {
  viewModel.typeSelection = .cat
  viewModel.ageSelection = .baby

  viewModel.clearFilters()

  XCTAssertTrue(viewModel.searchText.isEmpty)
  XCTAssertEqual(viewModel.ageSelection, .none)
  XCTAssertEqual(viewModel.typeSelection, .none)
  XCTAssertFalse(viewModel.shouldFilter)
}

First, you select the type cat, age baby and add a query for Kiki. Then you clear the filters and check if the view should still filter since seartchText isn’t empty.

The second method does the same but with a searchText empty, so shouldFilter should be false after calling clearFilters().

Build and run the tests.

Clear filters tests passed
Clear filters tests passed

Testing suggestion selection

All that’s left is to test when the user selects a suggestion from SuggestionGrid.

Finally, still in SearchViewModelTestCase.swift, add the following test:

func testSelectTypeSuggestion() {
  viewModel.selectTypeSuggestion(.cat)

  XCTAssertTrue(viewModel.searchText.isEmpty)
  XCTAssertEqual(viewModel.ageSelection, .none)
  XCTAssertEqual(viewModel.typeSelection, .cat)
  XCTAssertTrue(viewModel.shouldFilter)
}

This method calls selectTypeSuggestion(_:) and checks if the only property that changed was typeSelection.

Build and run the test case.

All tests passed
All tests passed

Key points

  • The new searchable(text:placement:prompt:) modifier adds a search bar that you can use to search with text.

  • You can use view models to search data locally and make requests to an external web API.

  • Extracting views to share code between features is easy with SwiftUI.

  • You can use @ViewBuilder to create SwiftUI views that take other views in a closure.

  • callAsFunctions is great for types that behave like functions.

Where to go from here?

Nice work! You now have a complete search feature in PetSave. This is the second feature you’ve developed so far, you should be proud. In the next chapter, you’ll learn about modularization and work on the onboarding feature for PetSave.

If you want to learn how to query Core Data using NSPredicate with a text from the search bar, checkout out our article Dynamic Core Data with SwiftUI Tutorial for iOS.

To learn more SwiftUI views and @ViewBuilders, check out our book SwiftUI Apprentice.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

© 2022 Razeware LLC