With the WeatherService protocol in place, we can refactor the view models that need to provide their views with weather data. In this episode, we focus on the LocationCell by refactoring the LocationCellViewModel class.
Injecting the Weather Service
Open LocationCellViewModel.swift and declare a private, constant property with name weatherService of type WeatherService.
import Foundation
struct LocationCellViewModel: Identifiable {
// MARK: - Properties
private let location: Location
// MARK: -
private let weatherService: WeatherService
...
}
We also declare a Published, private, variable property with name weatherData of type WeatherData?. The weatherData property stores the result of the request the view model sends to the Clear Sky API.
import Foundation
struct LocationCellViewModel: Identifiable {
// MARK: - Properties
private let location: Location
// MARK: -
private let weatherService: WeatherService
// MARK: -
@Published private var weatherData: WeatherData?
...
}
We have a problem, though. The Published property wrapper can only be applied to properties of classes. We resolve this issue by making the LocationCellViewModel struct a final class.
import Foundation
final class LocationCellViewModel: Identifiable {
...
}
We can make a few more improvements. We conform the LocationCellViewModel class to the ObservableObject protocol so the view can observe the view model.
import Foundation
final class LocationCellViewModel: Identifiable, ObservableObject {
}
Because LocationCellViewModel is now a class, we don't need to explicitly implement the id property of the Identifiable protocol. The Identifiable protocol provides a default implementation for classes, which means we can remove the computed id property of the LocationCellViewModel class.
We inject the WeatherService object through the initializer. Add a parameter to the initializer with name weatherService of type WeatherService. In the body of the initializer, we store a reference to the WeatherService object in the weatherService property.
// MARK: - Initialization
init(
location: Location,
weatherService: WeatherService
) {
self.location = location
self.weatherService = weatherService
}
Fetching Weather Data
The next step is fetching weather data. Add a start() method to the LocationCellViewModel class. The method is asynchronous. The weather(for:) method is throwing so we add a do-catch statement. In the do clause, we invoke the weather(for:) method on the WeatherService object, passing in the Location object. We assign the return value of the weather(for:) method to the weatherData property. In the catch clause, we print the error to the console.
func start() async {
do {
weatherData = try await weatherService.weather(for: location)
} catch {
print("Unable to Fetch Weather Data for Location \(error)")
}
}
Open LocationCell.swift. We apply the ObservedObject property wrapper to the view's viewModel property.
import SwiftUI
struct LocationCell: View {
// MARK: - Properties
@ObservedObject var viewModel: LocationCellViewModel
...
}
To start the view model, we create a Task using the task view modifier. In the closure we pass to the task view modifier, we invoke the start() method of the view model.
import SwiftUI
struct LocationCell: View {
// MARK: - Properties
@ObservedObject var viewModel: LocationCellViewModel
// MARK: - View
var body: some View {
...
.task {
await viewModel.start()
}
}
}
Formatting Weather Data
Revisit LocationCellViewModel.swift. We need to update the implementations of the computed summary, windSpeed, and temperature properties. Updating the computed summary property is easy. We access the current weather conditions through the currently property of the WeatherData object and return the value of its summary property.
var summary: String? {
weatherData?.currently.summary
}
The wind speed and temperature need a bit more work because we need to format the raw value the Clear Sky API returns. We do that with a MeasurementFormatter instance. Declare a private, constant property with name measurementFormatter of type MeasurementFormatter. We use a self-executing closure to create the MeasurementFormatter instance. In the self-executing closure, we create a NumberFormatter instance and set its usesSignificantDigits property to false. We then create the MeasurementFormatter instance and set its numberFormatter property to the NumberFormatter instance we created earlier. We return the MeasurementFormatter instance from the self-executing closure.
private let measurementFormatter: MeasurementFormatter = {
let numberFormatter = NumberFormatter()
numberFormatter.usesSignificantDigits = false
let measurementFormatter = MeasurementFormatter()
measurementFormatter.numberFormatter = numberFormatter
return measurementFormatter
}()
Let's update the computed windSpeed property. We use a guard statement to safely access the wind speed of the current weather conditions and return nil if the weatherData property is equal to nil.
var windSpeed: String? {
guard let windSpeed = weatherData?.currently.windSpeed else {
return nil
}
}
To format the wind speed, we first need to create a Measurement object. We pass the wind speed as a Double to the initializer and specify the unit of the wind speed, miles per hour in this example.
var windSpeed: String? {
guard let windSpeed = weatherData?.currently.windSpeed else {
return nil
}
let measurement = Measurement(
value: Double(windSpeed),
unit: UnitSpeed.milesPerHour
)
}
To format the wind speed, we invoke the string(from:) method of the MeasurementFormatter instance, passing in the Measurement object. The result is a nicely formatted string.
var windSpeed: String? {
guard let windSpeed = weatherData?.currently.windSpeed else {
return nil
}
let measurement = Measurement(
value: Double(windSpeed),
unit: UnitSpeed.milesPerHour
)
return measurementFormatter.string(from: measurement)
}
We repeat these steps for the computed temperature property. We use a guard statement to safely access the temperature of the current weather conditions and return nil if the weatherData property is equal to nil. Formatting the temperature is identical to formatting the wind speed with the exception of the unit of the Measurement object, Fahrenheit instead of miles per hour.
var temperature: String? {
guard let temperature = weatherData?.currently.temperature else {
return nil
}
let measurement = Measurement(
value: Double(temperature),
unit: UnitTemperature.fahrenheit
)
return measurementFormatter.string(from: measurement)
}
Main Actor
Earlier in this series, we ran into a threading issue. We were able to resolve the issue by taking advantage of the main actor. If we don't apply the MainActor attribute to the LocationCellViewModel class, we inevitably run into the same issue. Why is that?
In the start() method, the view model fetches weather data on a background thread and assigns the result to the weatherData property. That assignment also occurs on the background thread. Because the weatherData property drives the user interface, we run the risk that the user interface is updated on that same background thread. We avoid this risk by making sure the weatherData property is always updated on the main thread. As you may remember, we do this by applying the MainActor attribute to the LocationCellViewModel class.
import Foundation
@MainActor
final class LocationCellViewModel: Identifiable, ObservableObject {
...
}
Passing the Weather Service Around
Earlier in this series, I showed you how we create a Store instance in the ThunderstormApp struct and pass it around to the objects that need it. That is a classic example of dependency injection. We do the same with the WeatherClient instance.
Open LocationsViewModel.swift and declare a private, constant property with name weatherService of type WeatherService.
import Combine
import Foundation
@MainActor
final class LocationsViewModel: ObservableObject {
// MARK: - Properties
private let store: Store
private let weatherService: WeatherService
...
}
Navigate to the initializer and add a parameter with name weatherService of type WeatherService. In the body of the initializer, we store a reference to the WeatherService object in the weatherService property.
// MARK: - Initialization
init(
store: Store,
weatherService: WeatherService
) {
self.store = store
self.weatherService = weatherService
}
We also need to update the start() method of the LocationsViewModel class. We need to refactor the use of the map operator. To avoid referencing self, the LocationsViewModel instance, in the closure we pass to the mapoperator, we declare a local constant that stores a reference to theWeatherServiceobject. We pass the reference to theWeatherServiceobject that is stored in theweatherServiceconstant to the initializer of theLocationCellViewModel` class.
// MARK: - Public APi
func start() {
let weatherService = self.weatherService
store.locationsPublisher
.map { locations in
locations.map { location in
LocationCellViewModel(
location: location,
weatherService: weatherService
)
}
}
.eraseToAnyPublisher()
.assign(to: &$locationCellViewModels)
}
Open ThunderstormApp.swift. We create a WeatherClient instance and pass it to the initializer of the LocationsViewModel class.
import SwiftUI
@main
struct ThunderstormApp: App {
// MARK: - App
var body: some Scene {
WindowGroup {
LocationsView(
viewModel: .init(
store: UserDefaults.standard,
weatherService: WeatherClient()
)
)
}
}
}
Open LocationsView.swift and navigate to the LocationsView_Previews struct. We create a WeatherPreviewClient object and pass it to the initializer of the LocationsViewModel class.
struct LocationsView_Previews: PreviewProvider {
static var previews: some View {
LocationsView(
viewModel: .init(
store: PreviewStore(),
weatherService: WeatherPreviewClient()
)
)
}
}
Open LocationCell.swift and navigate to the LocationCell_Previews struct. We create a WeatherPreviewClient object and pass it to the initializer of the LocationCellViewModel class.
struct LocationCell_Previews: PreviewProvider {
static var previews: some View {
let viewModel = LocationCellViewModel(
location: .preview,
weatherService: WeatherPreviewClient()
)
return LocationCell(viewModel: viewModel)
}
}
Build and run the application in the simulator. The LocationsView now displays weather data its view model fetched from the Clear Sky API. It shows a progress view as long as the GET request is in flight.
What's Next?
In the next episode, I show you a simple technique to avoid code duplication. In the next few episodes, we continue to use the MeasurementFormatter class to format the raw values the Clear Sky API returns. This might result in quite a bit of code duplication. That is something we need to avoid.