The story so far
In the first article in this series, we introduced the OpenWeatherMap service and showed you how to send it a request for the current weather in a specified city and get a JSON response back, first manually, then programmatically. We expanded on this in the second article, where we gave the app the ability to convert that JSON into a dictionary from which Swift can easily extract data.
In both articles, the weather information wasn’t being presented in the user interface. All output was printed out to the debug console. It’s now time to make our app display information to the user. By the end of this article, we want our app to look like this:
Let’s get started!
The WeatherGetter
class
So far, we’ve spent most of our time working on the WeatherGetter
class, which connects to OpenWeatherMap.org, provides it with a city name, and retrieves the current weather for that city.
WeatherGetter
makes use of:
- the Shared Session instance of the
NSURLSession
class (which provides an API for sending and receiving data to and from a given URL), and - an instance of
NSURLSessionDataTask
, which downloads data from a specified URL into memory.
Here’s how these classes relate to each other:
The user interface lives in the view controller, while the weather networking apparatus lives in WeatherGetter
. Here’s the setup we’re aiming for:
We create this setup using delegation, a design pattern that you’ll often see in iOS programming when one object coordinates its activities with another object:
We’ll add a protocol definition to WeatherGetter
:
protocol WeatherGetterDelegate { func didGetWeather(weather: Weather) func didNotGetWeather(error: NSError) }
This definition specifies the interface for two methods:
didGetWeather
, which we’ll call if we were able to retrieve the weather data from OpenWeatherMap and parse its contents. It will provide the weather data in the form of aWeather
struct, which we’ll talk about in a moment.didNotGetWeather
, which we’ll call if we were not able to retrieve the weather data from OpenWeatherMap or if we weren’t able parse its contents. It will provide an error object that will explain what kind of error occurred.
We’ll have the view controller register itself with WeatherGetter
, and both didGetWeather
and didNotGetWeather
will be implemented in the view controller.
Here’s the complete WeatherGetterDelegate.swift file:
import Foundation // MARK: WeatherGetterDelegate // =========================== // WeatherGetter should be used by a class or struct, and that class or struct // should adopt this protocol and register itself as the delegate. // The delegate's didGetWeather method is called if the weather data was // acquired from OpenWeatherMap.org and successfully converted from JSON into // a Swift dictionary. // The delegate's didNotGetWeather method is called if either: // - The weather was not acquired from OpenWeatherMap.org, or // - The received weather data could not be converted from JSON into a dictionary. protocol WeatherGetterDelegate { func didGetWeather(weather: Weather) func didNotGetWeather(error: NSError) } // MARK: WeatherGetter // =================== class WeatherGetter { private let openWeatherMapBaseURL = "http://api.openweathermap.org/data/2.5/weather" private let openWeatherMapAPIKey = "YOUR API CODE HERE" private var delegate: WeatherGetterDelegate // MARK: - init(delegate: WeatherGetterDelegate) { self.delegate = delegate } func getWeatherByCity(city: String) { let weatherRequestURL = NSURL(string: "\(openWeatherMapBaseURL)?APPID=\(openWeatherMapAPIKey)&q=\(city)")! getWeather(weatherRequestURL) } private func getWeather(weatherRequestURL: NSURL) { // This is a pretty simple networking task, so the shared session will do. let session = NSURLSession.sharedSession() // The data task retrieves the data. let dataTask = session.dataTaskWithURL(weatherRequestURL) { (data: NSData?, response: NSURLResponse?, error: NSError?) in if let networkError = error { // Case 1: Error // An error occurred while trying to get data from the server. self.delegate.didNotGetWeather(networkError) } else { // Case 2: Success // We got data from the server! do { // Try to convert that data into a Swift dictionary let weatherData = try NSJSONSerialization.JSONObjectWithData( data!, options: .MutableContainers) as! [String: AnyObject] // If we made it to this point, we've successfully converted the // JSON-formatted weather data into a Swift dictionary. // Let's now used that dictionary to initialize a Weather struct. let weather = Weather(weatherData: weatherData) // Now that we have the Weather struct, let's notify the view controller, // which will use it to display the weather to the user. self.delegate.didGetWeather(weather) } catch let jsonError as NSError { // An error occurred while trying to convert the data into a Swift dictionary. self.delegate.didNotGetWeather(jsonError) } } } // The data task is set up...launch it! dataTask.resume() } }
The Weather
struct
In order to more easily send the weather data from WeatherGetter
to the view controller, I created a struct called Weather
. It lives in a file called Weather.swift, and it’s shown in its entirety below:
import Foundation struct Weather { let dateAndTime: NSDate let city: String let country: String let longitude: Double let latitude: Double let weatherID: Int let mainWeather: String let weatherDescription: String let weatherIconID: String // OpenWeatherMap reports temperature in Kelvin, // which is why we provide celsius and fahrenheit // computed properties. private let temp: Double var tempCelsius: Double { get { return temp - 273.15 } } var tempFahrenheit: Double { get { return (temp - 273.15) * 1.8 + 32 } } let humidity: Int let pressure: Int let cloudCover: Int let windSpeed: Double // These properties are optionals because OpenWeatherMap doesn't provide: // - a value for wind direction when the wind speed is negligible // - rain info when there is no rainfall let windDirection: Double? let rainfallInLast3Hours: Double? let sunrise: NSDate let sunset: NSDate init(weatherData: [String: AnyObject]) { dateAndTime = NSDate(timeIntervalSince1970: weatherData["dt"] as! NSTimeInterval) city = weatherData["name"] as! String let coordDict = weatherData["coord"] as! [String: AnyObject] longitude = coordDict["lon"] as! Double latitude = coordDict["lat"] as! Double let weatherDict = weatherData["weather"]![0] as! [String: AnyObject] weatherID = weatherDict["id"] as! Int mainWeather = weatherDict["main"] as! String weatherDescription = weatherDict["description"] as! String weatherIconID = weatherDict["icon"] as! String let mainDict = weatherData["main"] as! [String: AnyObject] temp = mainDict["temp"] as! Double humidity = mainDict["humidity"] as! Int pressure = mainDict["pressure"] as! Int cloudCover = weatherData["clouds"]!["all"] as! Int let windDict = weatherData["wind"] as! [String: AnyObject] windSpeed = windDict["speed"] as! Double windDirection = windDict["deg"] as? Double if weatherData["rain"] != nil { let rainDict = weatherData["rain"] as! [String: AnyObject] rainfallInLast3Hours = rainDict["3h"] as? Double } else { rainfallInLast3Hours = nil } let sysDict = weatherData["sys"] as! [String: AnyObject] country = sysDict["country"] as! String sunrise = NSDate(timeIntervalSince1970: sysDict["sunrise"] as! NSTimeInterval) sunset = NSDate(timeIntervalSince1970:sysDict["sunset"] as! NSTimeInterval) } }
When initializing the Weather
struct, you pass it the [String: AnyObject]
dictionary created by parsing the JSON from OpenWeatherMap. Weather then takes that data from that dictionary and uses it to initialize its properties.
The Weather
struct does a number of useful things:
- It turns the dictionary-of-dictionaries structure of the data from OpenWeatherMap into a nice, flat set of weather properties,
- it provides the date/time values provided by OpenWeatherMap in iOS’ native NSDate format rather than in Unix time (an integer specifying time in seconds after January 1, 1970), and
- it uses computed properties to report temperatures in degrees Celsius and Fahrenheit rather than Kelvin.
You may notice that the windDirection
and rainfallInLast3Hours
properties are optionals. That’s because OpenWeatherMap doesn’t always provide those values. It reports a wind direction if and only if the wind speed is greater than zero, and it reports rain information if and only if it’s raining.
The view controller
Here are the controls I put on the view, along with the names of their outlets:
I also created a Touch Up Inside action for the Get weather for the city above button called getWeatherForCityButtonTapped
.
Here’s the code for ViewController.swift:
import UIKit class ViewController: UIViewController, WeatherGetterDelegate, UITextFieldDelegate { @IBOutlet weak var cityLabel: UILabel! @IBOutlet weak var weatherLabel: UILabel! @IBOutlet weak var temperatureLabel: UILabel! @IBOutlet weak var cloudCoverLabel: UILabel! @IBOutlet weak var windLabel: UILabel! @IBOutlet weak var rainLabel: UILabel! @IBOutlet weak var humidityLabel: UILabel! @IBOutlet weak var cityTextField: UITextField! @IBOutlet weak var getCityWeatherButton: UIButton! var weather: WeatherGetter! // MARK: - override func viewDidLoad() { super.viewDidLoad() weather = WeatherGetter(delegate: self) // Initialize UI // ------------- cityLabel.text = "simple weather" weatherLabel.text = "" temperatureLabel.text = "" cloudCoverLabel.text = "" windLabel.text = "" rainLabel.text = "" humidityLabel.text = "" cityTextField.text = "" cityTextField.placeholder = "Enter city name" cityTextField.delegate = self cityTextField.enablesReturnKeyAutomatically = true getCityWeatherButton.enabled = false } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() } // MARK: - Button events // --------------------- @IBAction func getWeatherForCityButtonTapped(sender: UIButton) { guard let text = cityTextField.text where !text.isEmpty else { return } weather.getWeather(cityTextField.text!.urlEncoded) } // MARK: - // MARK: WeatherGetterDelegate methods // ----------------------------------- func didGetWeather(weather: Weather) { // This method is called asynchronously, which means it won't execute in the main queue. // ALl UI code needs to execute in the main queue, which is why we're wrapping the code // that updates all the labels in a dispatch_async() call. dispatch_async(dispatch_get_main_queue()) { self.cityLabel.text = weather.city self.weatherLabel.text = weather.weatherDescription self.temperatureLabel.text = "\(Int(round(weather.tempCelsius)))°" self.cloudCoverLabel.text = "\(weather.cloudCover)%" self.windLabel.text = "\(weather.windSpeed) m/s" if let rain = weather.rainfallInLast3Hours { self.rainLabel.text = "\(rain) mm" } else { self.rainLabel.text = "None" } self.humidityLabel.text = "\(weather.humidity)%" } } func didNotGetWeather(error: NSError) { // This method is called asynchronously, which means it won't execute in the main queue. // ALl UI code needs to execute in the main queue, which is why we're wrapping the call // to showSimpleAlert(title:message:) in a dispatch_async() call. dispatch_async(dispatch_get_main_queue()) { self.showSimpleAlert(title: "Can't get the weather", message: "The weather service isn't responding.") } print("didNotGetWeather error: \(error)") } // MARK: - UITextFieldDelegate and related methods // ----------------------------------------------- // Enable the "Get weather for the city above" button // if the city text field contains any text, // disable it otherwise. func textField(textField: UITextField, shouldChangeCharactersInRange range: NSRange, replacementString string: String) -> Bool { let currentText = textField.text ?? "" let prospectiveText = (currentText as NSString).stringByReplacingCharactersInRange( range, withString: string) getCityWeatherButton.enabled = prospectiveText.characters.count > 0 print("Count: \(prospectiveText.characters.count)") return true } // Pressing the clear button on the text field (the x-in-a-circle button // on the right side of the field) func textFieldShouldClear(textField: UITextField) -> Bool { // Even though pressing the clear button clears the text field, // this line is necessary. I'll explain in a later blog post. textField.text = "" getCityWeatherButton.enabled = false return true } // Pressing the return button on the keyboard should be like // pressing the "Get weather for the city above" button. func textFieldShouldReturn(textField: UITextField) -> Bool { textField.resignFirstResponder() getWeatherForCityButtonTapped(getCityWeatherButton) return true } // Tapping on the view should dismiss the keyboard. override func touchesBegan(touches: Set<UITouch>, withEvent event: UIEvent?) { view.endEditing(true) } // MARK: - Utility methods // ----------------------- func showSimpleAlert(title title: String, message: String) { let alert = UIAlertController( title: title, message: message, preferredStyle: .Alert ) let okAction = UIAlertAction( title: "OK", style: .Default, handler: nil ) alert.addAction(okAction) presentViewController( alert, animated: true, completion: nil ) } } extension String { // A handy method for %-encoding strings containing spaces and other // characters that need to be converted for use in URLs. var urlEncoded: String { return self.stringByAddingPercentEncodingWithAllowedCharacters(NSCharacterSet.URLUserAllowedCharacterSet())! } }
If you run the app, you should get results similar to this:
If the temperatures seem a little chilly to you, it’s because you’re probably expecting them in Fahrenheit and I’m currently displaying them in Celsius. You can change this by changing the line in the didGetWeather
method from
self.temperatureLabel.text = "\(Int(round(weather.tempCelsius)))°"
to
self.temperatureLabel.text = "\(Int(round(weather.tempFahrenheit)))°"
Take a good look at the code for the view controller — I’ve included a couple of tricks that I think improve the UI. I’ll explain them in greater detail in posts to follow.
In the next installment in this series, we’ll add geolocation capability to the app, so that the user can get the weather at his/her current location without having to type it in.
Download the project files
You can download the project files for this article (51KB zipped) here.
8 replies on “How to build an iOS weather app in Swift, part 3: Giving the app a user interface”
[…] At the end of the last article in this series, we had a weather app that looked like this: […]
[…] In the third article, we gave our weather app a user interface. […]
Hi,
I was wondering if you could help I’m getting the following error when running the app at this point:
2016-09-08 13:57:04.652 WeatherApp[4894:98417] *** Terminating app due to uncaught exception ‘NSUnknownKeyException’, reason: ‘[ setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key cityText.’
Thank you in advance!
[…] Part 3: Giving the app a user interface […]
Hi, is it possible for you to redo this tutorial for swift 4. half of your code doesn’t work and it would really be amazing if it did! thank you and reach out to me anytime
This is awesome. Thanks for the tutorial
This is very helpful for understanding OA to consume JSON web services. One question I have:
In the ViewController,
@IBAction func getWeatherForCityButtonTapped(sender: UIButton) {
guard let text = cityTextField.text where !text.isEmpty else {
return
}
weather.getWeather(cityTextField.text!.urlEncoded)
}
Shouldn’t this function call be changed to:
weather.getWeatherByCity(cityTextField.text!.urlEncoded)
Thanks for the app, though this produces a memory leak. To resolve, in WeatherGetter.swift add the following line immediately following the data task.resume() call:
dataTask.resume()
session.finishTasksAndInvalidate()
See further explanation here
https://stackoverflow.com/questions/28223345/memory-leak-when-using-nsurlsession-downloadtaskwithurl/35757989