The user can add locations using the AddLocationView, but there is room for improvement. The user experience isn't optimal at the moment. The AddLocationView doesn't show a progress view when the application is forward geocoding the address the user entered and the user is faced with an empty view if no matches are found. That is something we address in this episode.
Defining the States of the View
What I like most about the Model-View-ViewModel pattern is that the view doesn't know what it displays. It only knows how to display what it is given. It is easy, and sometimes tempting, to make a view a little too smart. A view should only display what it is given and know as little as possible about what it displays. The AddLocationView is a fine example to explain this concept in more detail.
The AddLocationView can be in one of four states, (1) it is empty when the TextField is empty, (2) it displays a progress view when the Core Location framework is forward geocoding the address the user entered, (3) it displays the results of the forward geocoding operation, or (4) it displays a message, for example, that no matches were found. Defining these states is an important starting point because it forces you to think about what the view needs to display in each of these states.
Open AddLocationViewModel.swift and declare an enum with name State that conforms to the Equatable protocol. Why the State enum conforms to the Equatable protocol becomes clear later in this episode.
import Combine
import Foundation
@MainActor
internal final class AddLocationViewModel: ObservableObject {
// MARK: - Types
enum State: Equatable {
}
...
}
We add a case to the State enum for each state we defined a few moments ago, empty, querying, message, and results. The view model should provide the message the view displays so we define an associated value of type String for the message case. We also define an associated value for the results case of type [AddLocationCellViewModel].
import Combine
import Foundation
@MainActor
internal final class AddLocationViewModel: ObservableObject {
// MARK: - Types
enum State: Equatable {
// MARK: - Cases
case empty
case querying
case message(String)
case results([AddLocationCellViewModel])
}
...
}
Before we continue, we need to resolve an error. The State enum doesn't conform to the Equatable protocol. To fix that, we conform the AddLocationCellViewModel struct to the Equatable protocol. Open AddLocationCellViewModel.swift and conform the AddLocationCellViewModel struct to the Equatable protocol.
import Foundation
struct AddLocationCellViewModel: Equatable, Identifiable {
...
}
It doesn't stop here. The Location struct also needs to conform to the Equatable protocol. Open Location.swift and conform the Location struct to the Equatable protocol.
import CoreLocation
struct Location: Codable, Equatable {
...
}
Revisit AddLocationViewModel.swift. Remove the computed addLocationCellViewModels property. The view accesses the array of view models through the State enum. Declare a Published, variable property with name state of type State. We declare the property's setter privately and set its initial value to empty.
@Published private(set) var state: State = .empty
We also declare a Published, private, variable property with name isQuerying and set its initial value to false.
@Published private var isQuerying = false
Navigate to the geocodeAddressString(_:) method. We set the isQuerying property to true before we create the Task and set the isQuerying property to false after the do-catch statement.
private func geocodeAddressString(_ addressString: String) {
isQuerying = true
Task {
do {
locations = try await geocodingService.geocodeAddressString(addressString)
} catch {
print("Unable to Geocode \(addressString) \(error)")
}
isQuerying = false
}
}
Adding Combine to the Mix
We use the Combine framework to update the view model's state property. Navigate to the setupBindings() method of the AddLocationViewModel class.
We first create a publisher with Output type [AddLocationCellViewModel] by applying the map operator to the locations publisher. In the closure we pass to the map operator, we invoke the map(_:) method on the array of Location objects, passing in a reference to the initializer of the AddLocationCellViewModel struct.
$locations
.map { $0.map(AddLocationCellViewModel.init) }
The next step is combining this publisher with the query and isQuerying publishers. We do this by applying the combineLatest operator to the publisher the map operator returns, passing in references to the query and isQuerying publishers.
$locations
.map { $0.map(AddLocationCellViewModel.init) }
.combineLatest($query, $isQuerying)
We apply the map operator to the publisher the combineLatest operator returns. The closure we pass to the map operator accepts three arguments, an array of AddLocationCellViewModel objects, the query the user entered in the TextField, and a boolean that indicates whether a forward geocoding operation is in flight. The closure's return value is of type State.
$locations
.map { $0.map(AddLocationCellViewModel.init) }
.combineLatest($query, $isQuerying)
.map { viewModels, query, isQuerying -> State in
}
We return querying from the closure if isQuerying is equal to true
$locations
.map { $0.map(AddLocationCellViewModel.init) }
.combineLatest($query, $isQuerying)
.map { viewModels, query, isQuerying -> State in
if isQuerying {
return .querying
}
}
We return empty from the closure if the query the user entered in the TextField is empty.
$locations
.map { $0.map(AddLocationCellViewModel.init) }
.combineLatest($query, $isQuerying)
.map { viewModels, query, isQuerying -> State in
if isQuerying {
return .querying
}
if query.isEmpty {
return .empty
}
}
If the array of view models is empty, we return message with an associated value of No matches found .... If the array of view models isn't empty, we return results with the array of view models as the associated value.
$locations
.map { $0.map(AddLocationCellViewModel.init) }
.combineLatest($query, $isQuerying)
.map { viewModels, query, isQuerying -> State in
if isQuerying {
return .querying
}
if query.isEmpty {
return .empty
}
if viewModels.isEmpty {
return .message("No matches found ...")
} else {
return .results(viewModels)
}
}
We wrap the publisher the map operator returns with a type eraser using the eraseToAnyPublisher() method and remove duplicates by applying the removeDuplicates operator. That is why we conformed the State enum to the Equatable protocol earlier.
$locations
.map { $0.map(AddLocationCellViewModel.init) }
.combineLatest($query, $isQuerying)
.map { viewModels, query, isQuerying -> State in
if isQuerying {
return .querying
}
if query.isEmpty {
return .empty
}
if viewModels.isEmpty {
return .message("No matches found ...")
} else {
return .results(viewModels)
}
}
.eraseToAnyPublisher()
.removeDuplicates()
We assign the values the publisher emits to the state property using the assign(to:) method. The assign(to:) method defines one parameter, an in-out parameter. We pass the state publisher as an argument to the in-out parameter.
$locations
.map { $0.map(AddLocationCellViewModel.init) }
.combineLatest($query, $isQuerying)
.map { viewModels, query, isQuerying -> State in
if isQuerying {
return .querying
}
if query.isEmpty {
return .empty
}
if viewModels.isEmpty {
return .message("No matches found ...")
} else {
return .results(viewModels)
}
}
.eraseToAnyPublisher()
.removeDuplicates()
.assign(to: &$state)
Handling Errors
What happens if the geocodeAddressString(_:) method of the geocoding service throws an error? We could display an error to the user, but the Core Location framework throws an error if it isn't able to find placemarks for an address. For that reason, we keep it simple and fail gracefully by setting the locations property to an empty array in the catch clause.
private func geocodeAddressString(_ addressString: String) {
isQuerying = true
Task {
do {
locations = try await geocodingService.geocodeAddressString(addressString)
} catch {
locations = []
print("Unable to Geocode \(addressString) \(error)")
}
isQuerying = false
}
}
Multiple Solutions
I want to point out that the solution we implemented in this episode is one of many. For example, you might wonder why we need the isQuerying property. Why don't we update the state property in the geocodeAddressString(_:) method instead? That is a viable option and that solution isn't necessarily inferior to the solution we implemented in this episode. Which solution you choose is to some extent a personal preference.
There are several aspects I value about the solution we implemented in this episode. For example, I like how Combine makes the implementation easy to understand. It is clear which variables have an impact on the value of the state property and the removeDuplicates operator makes it trivial to discard duplicate values. The Model-View-ViewModel pattern works very well with a reactive approach.
What's Next?
In the next episode, we refactor the AddLocationView by leveraging the State enum we defined in this episode. The view no longer asks the view model for an array of view models. Instead it observes the view model's state publisher and switches on the State objects it emits.