Categories
Uncategorized

How to build an iOS weather app in Swift, part 3: Giving the app a user interface

weather

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:

tampa forecast

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:

view controller - weathergetter relationship 1

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:

view controller - weathergetter relationship 2

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 a Weather 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:

view controller layout

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:

tampa forecast

st. johns forecast

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.

web horizontal rule

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

xcode download

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”

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!

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 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)

Comments are closed.