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
andPicker
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:
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.
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:
-
You ensure both views behave the same way. They’re both
List
s of animals. -
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 List
s inside AnimalsNearYouView
and SearchView
is the ProgressView
at 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
:
-
It defines a new view with the generic parameters
Content
andData
. Then, it defines constraints to those types,Content
being aView
,Data
aRandomAccessCollection
andData.Element
anAnimalEntity
. Now, you can useAnimalListView
with any type of collection, as long as it’s a collection ofAnimalEntity
. -
A property for holding the list’s footer view.
-
An initializer that takes an array of animal entities and a view builder closure for the footer view.
-
A second initializer that takes only an array of animal entities. This initializer uses an empty view for the list’s footer.
-
The body of the view, laying down a list with rows of animals.
-
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.
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:
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.
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:
-
Here, you declare a new service,
AnimalSearcherService
, with a request manager to make requests to Petfinder’s API. -
Then, you extend
AnimalSearcherService
to conform toAnimalSearcher
. -
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. -
Here, you make a request with the given data and return an array of animals.
-
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:
-
You start a request passing the text the user typed as a parameter. For now, you pass
none
forage
andtype
. You’ll add those filters later. -
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.
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:
-
onChange(of:perform:)
is a modifier that observes changes to a type that conforms toEquatable
, in this caseviewModel.searchText
. -
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.
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.
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.
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:
-
First, it adds a
Picker
view for selecting the pet’s age. This value can either bebaby
,young
,adult
orsenior
which are the cases forAnimalSearchAge
. -
Then it adds an
onChange(of:perform:)
view modifier that triggers a call toviewModel.search
when an age is selected. -
It adds another
Picker
view for selecting the pet’s type. This value can becat
,dog
,rabbit
,smallAndFurry
,horse
,bird
,scalesFinsAndOther
orbarnyard
, which are the cases forAnimalSearchType
. -
Then it adds an
onChange(of:perform:)
view modifier that also triggers a call toviewModel.search
, but this time, with the selected type. -
It adds two buttons, one for clearing both filters and another for dismissing the view.
-
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.
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:
- Adds a new button in the top right corner of the toolbar.
- It presents
SearchFilterView
inside aNavigationView
usingsheet(isPresented:onDismiss:content:)
. The modal will appear whenfilterPickerIsPresented
istrue
.
Build and run. Open the new filter view and select an age and type.
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:
-
Declare a regular struct and name it FilterAnimals.
-
Declare properties for the animals you want to filter, the query from the search bar and the age and type selected.
-
Implement a method called
callAsFunction
that Swift forwards whenever you call this type like a function. -
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.
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:
- 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. - 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. - 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.
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.
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.
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.
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.
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.