Build a Weather iOS App

Learn to create a weather forecasting app using the UIKit and Network frameworks in Swift.

April 04, 2020

0 views


Interested in this project?

Continue Learning

In this tutorial, we will be creating a weather forecasting app using Swift. This app will contain two screens: the main screen will show the current weather and temperature while the second screen will show the weather forecast for the next five days.

Let's start this by first creating a new single view application and naming it whatever you feel it. I went with "WeatherApp". Save the project where you please and then head over to the scene delegate file.

Since we will not be using storyboards - we will first set up our UIWindow and the root ViewController. In this particular scene delegate method, write this code to set up our user interface.

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
    // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
    // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

    guard let winScene = (scene as? UIWindowScene) else { return }

    let vc = UINavigationController(rootViewController: ViewController())
    window = UIWindow(windowScene: winScene)
    window?.rootViewController = vc
    window?.makeKeyAndVisible()
}

Once done, you may delete the main.storyboard file. In the General tab, go ahead and remove the Main from this drop down menu.

Last step, head over to the info.plist file and remove the Storyboard key from application scene manifest.

That's it! We have removed the storyboard and the entire app UI will be created programmatically.

The major part of this app revolves around networking and parsing the data we get from an API. The API service we will be using is called OpenWeatherMap. We will be using the free service which provides us the current weather and five day (3 hour forecast) for over 200,000 cities around the world.

Calling API & Parsing Data

Let's begin by first creating a new group called NetworkManager and inside creating a new Swift file called NetworkManagerProtocol. This protocol will allow us to keep the front-end (UI/CollectionView) separate from the backend logic (fetching data from the API). This way, if any one of you want to connect your own backend service, you can do so really easily.

The NetworkManagerProtocol will contain three functions:

protocol NetworkManagerProtocol {
    func fetchCurrentWeather(city: String, completion: @escaping (WeatherModel) -> ())
    func fetchCurrentLocationWeather(lat: String, lon: String, completion: @escaping (WeatherModel) -> ())
    func fetchNextFiveWeatherForecast(city: String, completion: @escaping ([ForecastTemperature]) -> ())
}

Now, create a new swift file under the NetworkManager group and call it WeatherNetworkManager. Create a class of the same name, and conform it to the NetworkManagerProtocol.

class WeatherNetworkManager : NetworkManagerProtocol {
  //3 protocol methods will appear here
}

Before going any further, let's take a look at the data we get from the OpenWeatherAPI. Initially when you sign up on the OpenWeatherAPI website, you are given an API key. Using that api key, you can perform a GET request to get the current weather in any city around the globe. Here is how:

Go to this URL in your browser:

api.openweathermap.org/data/2.5/weather?q={city name}&appid={your api key}
  • {city name} insert the city name.
  • {your api key} insert your API key.

This will return such json:

{
   "coord":{
      "lon":-0.13,
      "lat":51.51
   },
   "weather":[
      {
         "id":803,
         "main":"Clouds",
         "description":"broken clouds",
         "icon":"04d"
      }
   ],
   "base":"stations",
   "main":{
      "temp":279.99,
      "feels_like":272.77,
      "temp_min":278.15,
      "temp_max":281.48,
      "pressure":1038,
      "humidity":45
   },
   "visibility":10000,
   "wind":{
      "speed":6.7,
      "deg":50,
      "gust":13.4
   },
   "clouds":{
      "all":75
   },
   "dt":1585497356,
   "sys":{
      "type":1,
      "id":1414,
      "country":"GB",
      "sunrise":1585460504,
      "sunset":1585506501
   },
   "timezone":3600,
   "id":2643743,
   "name":"London",
   "cod":200
}

Now, we need to parse this data and make our own data model from it. We don't have to use all the data fields that are provided here. We will start from the top and see what data types we need to create. Let's start from the first object, that is weather. This object contains the id, main, description, icon. So let's create this data type:

struct Weather: Codable {
    let id: Int
    let main: String
    let description: String
    let icon: String
}

Next, we will create the main object. The main object contains temp, feels_like, temp_min, temp_max. So let's create a data type:

struct Main: Codable {
    let temp: Float
    let feels_like: Float
    let temp_min: Float
    let temp_max: Float
    let pressure: Float
    let humidity: Float
}

We will do the same for sys.

struct Sys: Codable {
    let country: String?
    let sunrise: Int?
    let sunset: Int?
}

Lastly, we will create a struct that will contain all the above data types and all the remaining fields such as timezone, dt_txt, name.

struct WeatherModel: Codable {
    let weather: [Weather]
    let main: Main
    let sys: Sys
    let name: String?
    let dt: Int
    let timezone: Int?
    let dt_txt: String?
}

Note: In the JSON, the weather object has data within two square brackets []. This tells us that we need weather array inside WeatherModel as shown above. We have successfully create a data model using the JSON that we recieved from the OpenWeatherAPI. Now, we will decode this JSON. This is extremely easy and we can parse JSON in Swift with just one line of code.

Head back to the WeatherNetworkManager and inside the fetchCurrentWeather(city: String..) method, write this code:

let formattedCity = city.replacingOccurrences(of: " ", with: "+")
let API_URL = "http://api.openweathermap.org/data/2.5/weather?q=\(formattedCity)&appid=\(NetworkProperties.API_KEY)"

guard let url = URL(string: API_URL) else {
    fatalError()
}
             
let urlRequest = URLRequest(url: url)
URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in
guard let data = data else { return }

    do {
        let currentWeather = try JSONDecoder().decode(WeatherModel.self, from: data)
        completion(currentWeather)
    } catch {
         print(error)
    }

}.resume()

Note the API_URL, this is the exact url that I mentioned earlier, only in this case we are getting the city from the function parameter, and the API_KEY is saved as a static variable inside NetworkProperties class.

struct NetworkProperties {
    static let API_KEY = "5aa8d384afe4769c566762d5e85249ba"
}

The formattedCity variable replaces any spaces inside the city name with + sign. So San Francisco will become San+Francisco. This formattedCity variable gets passed into the GET request URL.

Then, we create a URL from the API_URL string. We create a URLRequest with our url and then start a URLSession with that URL request. This gives us a closure, with three parameters - data, response & error. If the data is not nil, we try to decode the data through JSONDecoder method. We pass the WeatherModel data type (which we created earlier) and the data which we get from the closure parameter. We do this entire decoding inside a Do / Catch block since this process may throw an error.

do {
    let currentWeather = try JSONDecoder().decode(WeatherModel.self, from: data)
   completion(currentWeather)
} catch {
    print(error)
}

Once the JSON is successfully decoded and gets saved as an instance of our data type (WeatherModel), we can simply call the completion handler and pass the data model.

Creating the UI

Now, we are getting the data and we are able to parse it. Next, we need to show that data on the device's screen. So, let's create the main screen. I won't be using storyboards in this, instead I will creating all the UI programmatically. Here is the main screen design.

We have a currentLocation label, then a currentDate label, the currentTemp label along with an tempSymbol image view and tempDescription label. Head over to the ViewController, and create few UILabels and an UIImageView. The best way to make views programmatically is through using closures. I will show you how to create one of these views, and after that I hope you would be able to set up the rest yourself.

let currentLocation: UILabel = {
       let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.text = "...Location"
        label.textAlignment = .left
        label.numberOfLines = 0
        label.font = UIFont.systemFont(ofSize: 38, weight: .heavy)
        return label
    }()

The above block of code creates a label. Setting this label.translatesAutoresizingMaskIntoConstraints to false makes sure that we can set up auto layout on this view. Next, we will add this label to the view

view.addSubview(currentLocation)

Then, we will set up the constraints:

currentLocation.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20).isActive = true
currentLocation.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 18).isActive = true
currentLocation.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -18).isActive = true
currentLocation.heightAnchor.constraint(equalToConstant: 70).isActive = true

The constraints basically state that the currentLocation top anchor will connect to our view controller's view's top edge, with 20px padding. The leading anchor of the label (left side - if the text starts from left (English) / right side if it is Arabic) will connect to the view's leading anchor. Similarly we will connect the label to the trailing edge of the view. So now our label has the width (it stretches from left edge of the view to the right edge), the only thing left is the height, so we will provide the height ourselves.

This is how, a view can be programmatically created and viewed. Do the same thing with all the other views you want to show on the main screen. Now let's connect the models to the view.

Connecting Model to the View

In the ViewController, create a NetworkManager instance:

let networkManager = WeatherNetworkManager()

Create a new function, called loadData(city: String)

func loadData(city: String) {}

Inside the loadData method, call the networkManager.fetchCurrentWeather method.

networkManager.fetchCurrentWeather(city: city) { (weather) in
     let formatter = DateFormatter()
     formatter.dateFormat = "dd MMM yyyy" //yyyy
     let stringDate = formatter.string(from: Date(timeIntervalSince1970: TimeInterval(weather.dt)))

     DispatchQueue.main.async {
         self.currentTemperatureLabel.text = (String(weather.main.temp.kelvinToCelsiusConverter()) + "°C")
         self.currentLocation.text = "\(weather.name ?? "") , \(weather.sys.country ?? "")"
         self.tempDescription.text = weather.weather[0].description
         self.currentTime.text = stringDate
         self.minTemp.text = ("Min: " + String(weather.main.temp_min.kelvinToCelsiusConverter()) + "°C" )
         self.maxTemp.text = ("Max: " + String(weather.main.temp_max.kelvinToCelsiusConverter()) + "°C" )
         self.tempSymbol.loadImageFromURL(url: "http://openweathermap.org/img/wn/\(weather.weather[0].icon)@2x.png")
         UserDefaults.standard.set("\(weather.name ?? "")", forKey: "SelectedCity")
     }
}

When we call the method, we are getting our data inside the closure. The closures always run in the background thread and the UI should always be updated from the main thread. Therefore, we need to jump back to the main thread using DispatchQueue.main.async{}. Any code executed within this will run on the main thread.

This is now self-explanatory. I am setting all the UI labels and images views from the data that I receive from the closure.

self.currentTemperatureLabel.text = (String(weather.main.temp.kelvinToCelsiusConverter()) + "°C")

The kelvinToCeliusConverter is an extension to the Float type that converts the temperature provided to us by the API to celsius. Similarly, I am setting all the other labels and images from the WeatherModel above. tempSymbol is the image view that shows an image of the current weather condition. This image is grabbed from the openweathermap's website.

The loadImageFromURL method that I am using here allows the images loaded from the web to be cached. This allows for smooth loading of images and doesn't use the cellular data every single time.

let imageCache = NSCache<AnyObject, AnyObject>()
extension UIImageView {
    func loadImageFromURL(url: String) {
        self.image = nil
        guard let URL = URL(string: url) else {
            print("No Image For this url", url)
            return
        }

        if let cachedImage = imageCache.object(forKey: url as AnyObject) as? UIImage {
            self.image = cachedImage
            return
        }

        DispatchQueue.global().async { [weak self] in
            if let data = try? Data(contentsOf: URL) {
                if let image = UIImage(data: data) {
                    let imageTocache = image
                    imageCache.setObject(imageTocache, forKey: url as AnyObject)

                    DispatchQueue.main.async {
                        self?.image = imageTocache
                    }
                }
            }
        }

    }

In order to add a city name, I am using a simple alert controller with a textfield, and a add button in the navigation bar.

@objc func handleAddPlaceButton() {
    let alertController = UIAlertController(title: "Add City", message: "", preferredStyle: .alert)
     alertController.addTextField { (textField : UITextField!) -> Void in
         textField.placeholder = "City Name"
     }
     let saveAction = UIAlertAction(title: "Add", style: .default, handler: { alert -> Void in
         let firstTextField = alertController.textFields![0] as UITextField
         print("City Name: \(firstTextField.text)")
        guard let cityname = firstTextField.text else { return }
        self.loadData(city: cityname) // Calling the loadData function
     })
     let cancelAction = UIAlertAction(title: "Cancel", style: .destructive, handler: { (action : UIAlertAction!) -> Void in
        print("Cancel")
     })


     alertController.addAction(saveAction)
     alertController.addAction(cancelAction)

     self.present(alertController, animated: true, completion: nil)
}

That's about it. Now whenever you tap on the add button on the nav bar, the alert controller pops up and asks you enter a city name, tapping the save button invokes the loadData function and you will see the all the data showing up on the screen. This should give you a basic idea on how to make network calls, design data models from JSON, design the backend architecture in such a way to maintain decoupling between the UI and the data, and lastly how to create programmatic UI!

Bonus: CollectionViewCompositionalLayout

The second screen of the app will show the forecast for the next five days. In this screen, we will use a collectionView and each cell will have a collectionView. (nested collectionViews). As you can see in the picture above, the black boxes shows the main CV and the cells. Then each black box, contains the blue box (CV) with cells.

Create a new view controller, ForecastViewController and add a collectionView.

collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: createCompositionalLayout())
collectionView.register(ForecastCell.self, forCellWithReuseIdentifier: ForecastCell.reuseIdentifier)
collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
collectionView.backgroundColor = .white
collectionView.delegate = self
collectionView.dataSource = self
view.addSubview(collectionView)

In the initializer, we will pass the createCompositionalLayout() function.

func createCompositionalLayout() -> UICollectionViewLayout {
        let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
            self.createForecastSection()
        }

        let config = UICollectionViewCompositionalLayoutConfiguration()
        layout.configuration = config
        return layout
    }

func createForecastSection() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .fractionalHeight(1))

   let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
   layoutItem.contentInsets = NSDirectionalEdgeInsets(top:5, leading: 5, bottom: 0, trailing: 5)

   let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(110))
   let layoutGroup = NSCollectionLayoutGroup.vertical(layoutSize: layoutGroupSize, subitems: [layoutItem])

   let layoutSection = NSCollectionLayoutSection(group: layoutGroup)
  // layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
   return layoutSection
}

Inside the createForecastSection, we are creating the collection view design. The CollectionViewCompositionalLayout divides the collection view in to

  • item,
  • group,
  • section.

The item is the smallest entity. In our case the item size has a fractionalWidth of 1 and fractionalHeight of 1. This height and width is that of the parent. The parent of the item is a group. The group has the fractionalWidth of 1 and heightDimension of 110px. This means, we are setting the group height as 110px and the width will stretch the entire collectionview's width. This group gets into the section and that's about it.

The API URL we will use to request five day forecast is this:

api.openweathermap.org/data/2.5/forecast?q={city name}&appid={your api key}

The data we are getting from the API is an array of temperatures every three hours for the next five days. In total, we have 40 data readings. The readings are not separated by the date, so we need to do all this data manipulation inside the fetchNextFiveWeatherForecast method inside the NetworkManager class. Make sure to check out the full project on my github to check the full code. Inside the fetchNextFiveWeatherForecast method, I am seperating the data into five arrays and I have created a new data model ForecastTemperature that contains an array of WeatherInfo and weekDay.

// basic idea:

forecastmodelArray = [currentDayTemp, secondDayTemp, thirdDayTemp, fourthDayTemp, fifthDayTemp] // 5 or 6 total

The forecastmodelArray consists of five or six ForecastTemperature instances, each instance contains a weekDay and WeatherInfo array. This array will be passed in to the collectionView inside the collection View cell.

So, back in ForecastViewController, where we are configuring the ForecastCell, we will pass the ForecastTemperature instance to the cell.

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
   let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ForecastCell.reuseIdentifier, for: indexPath) as! ForecastCell
   cell.configure(with: forecastData[indexPath.row])
   return cell
}Weath

Inside the ForecastCell, we have created another collectionView and set it up as we have done before. This time the createCompositionalLayout() is a bit different.

func createCompositionalLayout() -> UICollectionViewLayout {
    let layout = UICollectionViewCompositionalLayout { sectionIndex, layoutEnvironment in
        self.createHourlySection()
    }

    let config = UICollectionViewCompositionalLayoutConfiguration()
    layout.configuration = config
    return layout
}

func createHourlySection() -> NSCollectionLayoutSection {
    let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1/4), heightDimension: .fractionalHeight(0.75))

   let layoutItem = NSCollectionLayoutItem(layoutSize: itemSize)
   layoutItem.contentInsets = NSDirectionalEdgeInsets(top:5, leading: 5, bottom: 0, trailing: 5)

   let layoutGroupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .estimated(110))
   let layoutGroup = NSCollectionLayoutGroup.horizontal(layoutSize: layoutGroupSize, subitems: [layoutItem])

   let layoutSection = NSCollectionLayoutSection(group: layoutGroup)
   layoutSection.orthogonalScrollingBehavior = .groupPagingCentered

   return layoutSection
}

In createHourlySection method, we are giving the item size a width of 1/4 of the entire width of the group (which is the width of the collectionView). In the cellForItemAt method for the nested collectionView, we are configuring the cells:

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: HourlyCell.reuseIdentifier, for: indexPath) as! HourlyCell
    cell.configure(with: dailyForecast[indexPath.row])
    return cell
  }

The HourlyCell is the custom collection view cell we are using. In this cell, we have a label for the time, label for the temperature and an image view. Let's set these up:

func configure(with item: WeatherInfo) {
    let dateFormatterGet = DateFormatter()
    dateFormatterGet.dateFormat = "yyyy-MM-dd HH:mm:ss"

    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "HH:mm"

    if let date = dateFormatterGet.date(from: item.time) {
        hourlyTimeLabel.text = dateFormatter.string(from: date)
    }

    tempSymbol.loadImageFromURL(url: "http://openweathermap.org/img/wn/\(item.icon)@2x.png")
    tempLabel.text = String(item.temp.kelvinToCeliusConverter()) + " °C"
}
}

This is what we get:

So, here is how we can have nested collection views and show data in an advanced form. CollectionViews are extremely powerful and with Diffable Data Source and CollectionViewCompositionalLayout, some really beautiful layouts can be developed!

Here is how you can make a weather app in Swift!

Comments (0)