You should now have a good understanding of what the Model-View-ViewModel pattern is and how it can be used to cure some of the problems the Model-View-Controller pattern suffers from. We can do better, though. Data is currently flowing in one direction. The view controller asks its view model for data and populates the view it manages. This is fine and many projects can greatly benefit from this lightweight implementation of the Model-View-ViewModel pattern.
But it is time to show you how we can take the Model-View-ViewModel pattern to the next level. In the next few episodes, we discuss how MVVM can be used to not only populate a view with data but also respond to changes made by the user or a change of the environment.
One of Cloudy's features is the ability to search for and save locations. When the user selects one of the saved locations, Cloudy fetches weather data for that location and displays it to the user.
Let me show you how this works. When the user taps the location button in the top left, the locations view is shown. It lists the current location of the device as well as the user's saved locations.
To add a location to the list of saved locations, the user taps the plus button in the top left. This brings up the add location view.
The user enters the name of a city and Cloudy uses the Core Location framework to forward geocode the location. Under the hood, Cloudy asks the Core Location framework for the coordinates of the city the user entered.
When the user enters the name of a city in the search bar and taps the Search button, the view controller uses a CLGeocoder
instance to forward geocode the location. Forward geocoding is an asynchronous operation. The view controller updates its table view when the Core Location framework returns the results of the geocoding request.
The current implementation of this feature uses the Model-View-Controller pattern. But you want to know how we can improve this using the Model-View-ViewModel pattern. That is what you are here for.
How can we improve what we currently have? We will create a view model that is responsible for everything related to responding to the user's input. The view model will send the geocoding request to Apple's location services. This is an asynchronous operation. When the geocoding request completes, successfully or unsuccessfully, the view controller's table view is updated with the results of the geocoding request. This example will show you how powerful the Model-View-ViewModel pattern can be when it is correctly implemented.
Let's take a quick look at the current implementation, which uses the Model-View-Controller pattern. We are only focusing on the AddLocationViewController
class. It defines outlets for a table view and a search bar. It also maintains a list of Location
objects. These are the results the Core Location framework returns. The Location
type is a struct that makes working with the results of the Core Location framework easier. The CLGeocoder
instance is responsible for making the geocoding requests. Don't worry if you are not familiar with this class, it is very easy to use.
AddLocationViewController.swift
class AddLocationViewController: UIViewController {
// MARK: - Properties
@IBOutlet var tableView: UITableView!
@IBOutlet var searchBar: UISearchBar!
// MARK: -
private var locations: [Location] = []
// MARK: -
private lazy var geocoder = CLGeocoder()
// MARK: -
weak var delegate: AddLocationViewControllerDelegate?
...
}
We also define a delegate protocol, AddLocationViewControllerDelegate
, to notify the LocationsViewController
class of the user's selection. This isn't important for the rest of the discussion, though.
AddLocationViewController.swift
protocol AddLocationViewControllerDelegate: AnyObject {
func controller(_ controller: AddLocationViewController, didAddLocation location: Location)
}
The AddLocationViewController
class acts as the delegate of the search bar and it conforms to the UISearchBarDelegate
protocol. When the user taps the Search button, the text of the search bar is used as input for the geocoding request.
AddLocationViewController.swift
extension AddLocationViewController: UISearchBarDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
// Hide Keyboard
searchBar.resignFirstResponder()
// Forward Geocode Address String
geocode(addressString: searchBar.text)
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
// Hide Keyboard
searchBar.resignFirstResponder()
// Clear Locations
locations = []
// Update Table View
tableView.reloadData()
}
}
Notice that we already use a dash of MVVM in the UITableViewDataSource
protocol to populate the table view. We take it a few steps further in the next few episodes.
AddLocationViewController.swift
extension AddLocationViewController: UITableViewDataSource {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return locations.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: LocationTableViewCell.reuseIdentifier, for: indexPath) as? LocationTableViewCell else { fatalError("Unexpected Table View Cell") }
// Fetch Location
let location = locations[indexPath.row]
// Create View Model
let viewModel = LocationViewModel(location: location.location, locationAsString: location.name)
// Configure Table View Cell
cell.configure(withViewModel: viewModel)
return cell
}
}
The Core Location framework makes forward geocoding easy. The magic happens in the geocode(addressString:)
method.
AddLocationViewController.swift
private func geocode(addressString: String?) {
guard let addressString = addressString else {
// Clear Locations
locations = []
// Update Table View
tableView.reloadData()
return
}
// Geocode City
geocoder.geocodeAddressString(addressString) { [weak self] (placemarks, error) in
DispatchQueue.main.async {
// Process Forward Geocoding Response
self?.processResponse(withPlacemarks: placemarks, error: error)
}
}
}
If the user's input is empty, we clear the table view. If the user enters a valid location, we invoke geocodeAddressString(_:completionHandler:)
on the CLGeocoder
instance. Core Location returns an array of CLPlacemark
instances if the geocoding request is successful. The CLPlacemark
class is defined in the Core Location framework and is used to store the metadata for a geographic location.
The response of the geocoding request is handled in the processResponse(withPlacemarks:error:)
method. We create an array of Location
objects from the array of CLPlacemark
instances and update the table view. That's it.
AddLocationViewController.swift
private func processResponse(withPlacemarks placemarks: [CLPlacemark]?, error: Error?) {
if let error = error {
print("Unable to Forward Geocode Address (\(error))")
} else if let matches = placemarks {
// Update Locations
locations = matches.compactMap({ (match) -> Location? in
guard let name = match.name else { return nil }
guard let location = match.location else { return nil }
return Location(name: name, latitude: location.coordinate.latitude, longitude: location.coordinate.longitude)
})
// Update Table View
tableView.reloadData()
}
}
What's Next?
We covered the most important details of the AddLocationViewController
class. How can we lift the view controller from some of its responsibilities? In the next episode, I show you what I have in mind.