Implementing the Day View Model
Everything's in place to populate the day view controller. Open DayViewModel.swift. Press the Option key and select DayViewController.swift to open it in the Assistant Editor on the right. Implementing the DayViewModel
struct is surprisingly straightforward. We first need to inspect the DayViewController
class and figure out what type of data the view model needs to provide to populate its user interface.
Let's start with the date label. It displays the date of the weather data. It's the responsibility of the view model to take the raw value of the Dark Sky response and transform it into something the date label can display, that is, a string.
The CurrentWeatherConditions
struct has a time
property of type Date
. We use the Date
instance to create a string that can be displayed by the date label. For that to work, we need a date formatter. Define a property, dateFormatter
, of type DateFormatter
. We instantiate a DateFormatter
instance and assign it to the dateFormatter
property.
import Foundation
struct DayViewModel {
// MARK: - Properties
let weatherData: CurrentWeatherConditions
// MARK: -
private let dateFormatter = DateFormatter()
}
The view controller doesn't need access to the dateFormatter
property, which means we can declare it as private.
We define a computed property, date
, of type String
. In the getter of the computed property, we set the dateFormat
property of the date formatter and ask it to convert the value stored in the time
property of weatherData
to a string. That's it.
import Foundation
struct DayViewModel {
// MARK: - Properties
let weatherData: CurrentWeatherConditions
// MARK: -
private let dateFormatter = DateFormatter()
// MARK: -
var date: String {
// Configure Date Formatter
dateFormatter.dateFormat = "EEE, MMMM d YYYY"
// Convert Date to String
return dateFormatter.string(from: weatherData.time)
}
}
We can repeat these steps to populate the time label. The time label displays the time of the weather data. We define a computed property, time
, of type String
. In the getter of the computed property, we set the dateFormat
property of the date formatter and ask it to convert the value stored in the time
property of weatherData
to a string.
import Foundation
struct DayViewModel {
// MARK: - Properties
let weatherData: CurrentWeatherConditions
// MARK: -
private let dateFormatter = DateFormatter()
// MARK: -
var date: String {
// Configure Date Formatter
dateFormatter.dateFormat = "EEE, MMMM d YYYY"
// Convert Date to String
return dateFormatter.string(from: weatherData.time)
}
var time: String {
// Configure Date Formatter
dateFormatter.dateFormat = "hh:mm a"
// Convert Date to String
return dateFormatter.string(from: weatherData.time)
}
}
Populating the description label is trivial. We define a computed property, summary
, of type String
. The getter returns the value stored in the summary
property of weatherData
. It doesn't get any simpler than this.
var summary: String {
return weatherData.summary
}
The computed properties for the temperature and wind speed labels are almost as easy to implement. To populate the temperature label, we define a computed property, temperature
, of type String
. The value stored in the temperature
property of weatherData
is of type Double
and is in degrees Fahrenheit. We format the value and convert it to a string.
var temperature: String {
return String(format: "%.1f °F", weatherData.temperature)
}
To populate the wind speed label, we define a computed property, windSpeed
, of type String
. The value stored in the windSpeed
property of weatherData
is of type Double
and is in miles per hour. We format the value and convert it to a string.
var windSpeed: String {
return String(format: "%.f MPH", weatherData.windSpeed)
}
Setting the image
property of the image view is more complex. The first step is easy, but it requires a bit of work. The icon
property of weatherData
is of type String
. Its value is one of a fixed set of possible values. This is documented on the Dark Sky website.
The documentation explains that the Dark Sky API returns one of ten possible values. Rainstorm only displays the weather conditions. We don't make a distinction between day and night with the exception of clear-day
and clear-night
.
The icons I plan to use were created by Adam Whitcroft. While I won't cover the creation of an asset catalog in detail in this episode, I want to show you that it isn't difficult. Download the archive from Adam's website and unzip it. Select the folder named SVG and open Sun.svg in your image editor of choice. I use Sketch. For each asset, I create three versions, 1x, 2x, and 3x. Depending on the deployment target of the project, it isn't necessary to create a 1x version since every modern iOS device has a retina display.
Return to Xcode and open Assets.xcassets. Create a folder and name it Weather Icons. A bit of structure never hurts. Create a new image set and name it clear-day. Add the images you created a moment ago.
Later in this episode, we apply a tint to the image we display in the image view of the day view controller. To make this possible, we need to modify an attribute of the image set. With the image set selected, open the Attributes Inspector and set Render As to Template Image.
The idea is simple. By setting the rendering mode of the image set to Template Image, Xcode creates a template from the image by inspecting the image data. Color information is ignored if you set the rendering mode of an image set to Template Image.
If you don't want to spend the time creating the image sets or you're not comfortable using an image editor such as Sketch, then I suggest you download the finished project of this episode. Unzip the archive, locate the Resources folder, and add Assets.xcassets to your project. That saves you a few minutes of work.
Later in this series, we populate the week view controller with weather data. The table view cells of the week view controller also display an icon. To avoid code duplication, we implement a helper method on UIImage
. The helper method makes it easier to load an image from the asset catalog based on the response of the Dark Sky API. Let me show you how this works.
Create a new Swift file in the Extensions group and name it UIImage.swift. Add an import statement for the UIKit framework and create an extension for UIImage
. Define a class method, imageForIcon(with:)
, which returns an optional UIImage
instance.
import UIKit
extension UIImage {
class func imageForIcon(with name: String) -> UIImage? {
}
}
We use a switch
statement with two cases and a default clause. If the value of the name
parameter is equal to clear-day
, clear-night
, fog
, rain
, snow
, sleet
, or wind
, we instantiate a UIImage
instance by loading an image with that name.
import UIKit
extension UIImage {
class func imageForIcon(with name: String) -> UIImage? {
switch name {
case "clear-day",
"clear-night",
"fog",
"rain",
"snow",
"sleet",
"wind":
return UIImage(named: name)
}
}
}
If the name is equal to cloudy
, partly-cloudy-day
, or partly-cloudy-night
, we instantiate a UIImage
instance by loading an image with name cloudy.
import UIKit
extension UIImage {
class func imageForIcon(with name: String) -> UIImage? {
switch name {
case "clear-day",
"clear-night",
"fog",
"rain",
"snow",
"sleet",
"wind":
return UIImage(named: name)
case "cloudy",
"partly-cloudy-day",
"partly-cloudy-night":
return UIImage(named: "cloudy")
}
}
}
Because switch
statements need to be exhaustive, we add a default
clause in which we instantiate and return a UIImage
instance by loading an image with name clear-day. We could also return nil
in that scenario.
import UIKit
extension UIImage {
class func imageForIcon(with name: String) -> UIImage? {
switch name {
case "clear-day",
"clear-night",
"fog",
"rain",
"snow",
"sleet",
"wind":
return UIImage(named: name)
case "cloudy",
"partly-cloudy-day",
"partly-cloudy-night":
return UIImage(named: "cloudy")
default:
return UIImage(named: "clear-day")
}
}
}
With the class method on UIImage
in place, we can finalize the implementation of the DayViewModel
struct. To take advantage of the class method on UIImage
, we first need to add an import statement for the UIKit framework at the top.
import UIKit
struct DayViewModel {
// MARK: - Properties
let weatherData: CurrentWeatherConditions
...
}
We implement a computed property, image
, of type UIImage?
. The implementation is short and simple thanks to the imageForIcon(with:)
method. We pass the value stored in the icon
property of weatherData
to the class method.
var image: UIImage? {
return UIImage.imageForIcon(with: weatherData.icon)
}
Populating the Day View Controller
Open DayViewController.swift and navigate to the setupViewModel(with:)
method. The implementation isn't difficult thanks to the DayViewModel
struct. We ask the view model for the data the day view controller needs to populate its user interface elements.
// MARK: - Helper Methods
private func setupViewModel(with viewModel: DayViewModel) {
// Configure Labels
dateLabel.text = viewModel.date
timeLabel.text = viewModel.time
windSpeedLabel.text = viewModel.windSpeed
temperatureLabel.text = viewModel.temperature
descriptionLabel.text = viewModel.summary
// Configure Icon Image View
iconImageView.image = viewModel.image
}
This is one of the niceties of the Model-View-ViewModel pattern. The view controller remains focused. Its only responsibilities are populating the user interface and responding to user interaction. The nitty gritty details are handled by its view model. Because the view model doesn't have a link to the user interface of the view controller, it's easy to write unit tests for the view model. That's the focus of the next episode.
Build and Run
Run the application so see the result. You may notice a few issues. It takes a few seconds for the application to show the current weather conditions. It may even be possible that the application doesn't display any weather data. What's happening?
Xcode gives us a hint. Open the Issue Navigator on the left and select Runtime. We have one runtime issue. Xcode notifies us that we update the user interface from a background thread. The user interface of an application should always be updated from the main thread. Updating the user interface from a background thread results in unexpected behavior as you can see in this example.
We can verify this by adding a breakpoint to the setupViewModel(with:)
method. Run the application again and open the Debug Navigator on the left.
Notice that the labels are populated on a background thread. How is that possible? The stack trace shows us what's happening. The completion handler of the data task the RootViewModel
instance uses to fetch weather data from the Dark Sky API is executed on a background thread. That isn't surprising. It's expected behavior. The problem is that we invoke the closure stored in the didFetchWeatherData
property in the completion handler of the data task.
The issue is easy to resolve. To keep the implementation of the fetchWeatherData()
method simple, we handle the response of the Dark Sky API request on the main thread. This isn't as complicated as it sounds. We ask the DispatchQueue
class for a reference to the main queue. The main queue is the queue that is associated with the main thread. We hand it a block of work by passing a closure to its async(group:qos:flags:execute:)
method. In the closure, we handle the response of the Dark Sky API request and invoke the closure stored in the didFetchWeatherData
property.
private func fetchWeatherData() {
// Initialize Weather Request
let weatherRequest = WeatherRequest(baseUrl: WeatherService.authenticatedBaseUrl, location: Defaults.location)
// Create Data Task
URLSession.shared.dataTask(with: weatherRequest.url) { [weak self] (data, response, error) in
if let response = response as? HTTPURLResponse {
print("Status Code: \(response.statusCode)")
}
DispatchQueue.main.async {
if let error = error {
print("Unable to Fetch Weather Data \(error)")
self?.didFetchWeatherData?(nil, .noWeatherDataAvailable)
} else if let data = data {
// Initialize JSON Decoder
let decoder = JSONDecoder()
do {
// Decode JSON Response
let darkSkyResponse = try decoder.decode(DarkSkyResponse.self, from: data)
// Invoke Completion Handler
self?.didFetchWeatherData?(darkSkyResponse, nil)
} catch {
print("Unable to Decode JSON Response \(error)")
// Invoke Completion Handler
self?.didFetchWeatherData?(nil, .noWeatherDataAvailable)
}
} else {
self?.didFetchWeatherData?(nil, .noWeatherDataAvailable)
}
}
}.resume()
}
Run the application one more time. The runtime issue should disappear and the user interface should be updated moments after the application receives a response from the Dark Sky API.
Dates and Timestamps
If you take a good look at the user interface, you may notice that there's another issue. The year the date label displays is in the distant future. The Dark Sky API sends the application a timestamp, which the JSONDecoder
instance converts to a Date
instance.
The problem is that the Dark Sky API sends a Unix timestamp while the JSONDecoder
instance expects a different type of timestamp. A Unix timestamp defines a date as the number of seconds that have passed since January 1, 1970. The JSONDecoder
class expects a timestamp that defines a date as the number of seconds that have passed since January 1, 2001. That explains the difference we're seeing in the user interface.
The solution is simple, though. We need to configure the JSONDecoder
instance. Revisit the RootViewModel
class and set the date decoding strategy of the JSONDecoder
instance to secondsSince1970
. By setting the date decoding strategy to secondsSince1970
, we notify the JSONDecoder
instance that any timestamps it encounters are Unix timestamps.
// Initialize JSON Decoder
let decoder = JSONDecoder()
// Configure JSON Decoder
decoder.dateDecodingStrategy = .secondsSince1970
With this simple change, the issue should be resolved. Run the application to see the result.
Adding Polish
It takes a few moments before the application can display weather data to the user because the application needs to perform a request. The labels of the application display dummy text for as long as there's no weather data to display. This isn't pretty. We can do better. Let's display an activity indicator view for as long as the request is in flight.
Define an outlet, activityIndicatorView
, of type UIActivityIndicatorView
. We configure the UIActivityIndicatorView
instance in a didSet
property observer. We instruct it to start animating and it should hide itself from the moment it stops animating.
@IBOutlet var activityIndicatorView: UIActivityIndicatorView! {
didSet {
activityIndicatorView.startAnimating()
activityIndicatorView.hidesWhenStopped = true
}
}
Open Main.storyboard and add a UIActivityIndicatorView
instance to the view of the DayViewController
class. We don't need to set the size of the activity indicator view, but we need to add layout constraints to define its position. We position the activity indicator view at the center of the image view of the day view controller.
Open the Connections Inspector on the right, select the day view controller, and connect the activity indicator view with its outlet.
The labels and image view of the day view controller should be hidden if they don't have any weather data to display. This is easy to do with an outlet collection. Open DayViewController.swift and define an outlet collection, weatherDataViews
, of type [UIView]
. In a didSet
property observer, we hide the members of the collection.
@IBOutlet var weatherDataViews: [UIView]! {
didSet {
for view in weatherDataViews {
view.isHidden = true
}
}
}
In the setupViewModel(with:)
method, we instruct the activity indicator view to stop animating, we populate the labels and image view with weather data, and we show the members of the outlet collection.
// MARK: - Helper Methods
private func setupViewModel(with viewModel: DayViewModel) {
// Hide Activity Indicator View
activityIndicatorView.stopAnimating()
// Configure Labels
dateLabel.text = viewModel.date
timeLabel.text = viewModel.time
windSpeedLabel.text = viewModel.windSpeed
temperatureLabel.text = viewModel.temperature
descriptionLabel.text = viewModel.summary
// Configure Icon Image View
iconImageView.image = viewModel.image
// Show Weather Data Views
for view in weatherDataViews {
view.isHidden = false
}
}
Revisit Main.storyboard and connect the weatherDataViews
outlet with the labels and the image view of the day view controller. Outlet collections can be very convenient to avoid code duplication.
We could have hidden the labels and image view in the storyboard. That would work just fine. I prefer to hide the labels and image view in code to make it explicit that these views are initially hidden. It's a personal choice. I like it because it makes it easier to understand what's happening without having to browse the storyboard.
Before running the application one more time, navigate to the didSet
property observer of the iconImageView
property. Earlier in this episode, I mentioned that I planned to tint the image displayed by the image view of the day view controller. This is easy to do.
Revisit Styles.swift and define a static computed property, baseTintColor
, of type UIColor
. In the getter of the computed property, we return the value stored in the base
static property.
import UIKit
extension UIColor {
enum Rainstorm {
private static let base: UIColor = UIColor(red: 0.31, green: 0.72, blue: 0.83, alpha: 1.0)
static var baseTextColor: UIColor {
return base
}
static var baseTintColor: UIColor {
return base
}
static var baseBackgroundColor: UIColor {
return base
}
}
}
...
We also define a background color for the view of the day view controller, a light, gray color. We name the static property lightBackgroundColor
.
import UIKit
extension UIColor {
enum Rainstorm {
private static let base: UIColor = UIColor(red: 0.31, green: 0.72, blue: 0.83, alpha: 1.0)
static var baseTextColor: UIColor {
return base
}
static var baseTintColor: UIColor {
return base
}
static var baseBackgroundColor: UIColor {
return base
}
static let lightBackgroundColor: UIColor = UIColor(red: 0.975, green: 0.975, blue: 0.975, alpha: 1.0)
}
}
...
Revisit DayViewController.swift and set the tintColor
property of the image view of the day view controller in its didSet
property observer.
@IBOutlet var iconImageView: UIImageView! {
didSet {
iconImageView.contentMode = .scaleAspectFit
iconImageView.tintColor = UIColor.Rainstorm.baseTintColor
}
}
Navigate to the setupView()
method and update the background color of the day view controller's view. That's it. Run the application to see the result.
// MARK: - View Methods
private func setupView() {
// Configure View
view.backgroundColor = UIColor.Rainstorm.lightBackgroundColor
}
What's Next?
Even though we did quite a bit of work in this episode, there wasn't anything that was terribly complicated. If you're unfamiliar with threads and queues, then the runtime issue we ran into might have been a bit difficult to understand.
In the next episode, it's time we put the implementation of the DayViewModel
struct to the test by writing a handful of unit tests.