Example Project

The SwiftUI weather example

I have rewritten my earlier Weather example as a SwiftUI app. This version includes some new features, including a better display for weather information.

The Location and LocationInfo classes

The weather app allows users to specify their location information. That location information in turn gets used in fetching the weather forecast from the openweathermap.org web service.

To record the key information about the user's location we use a Location class:

import Foundation

class Location : Codable {
    var zip : String
    var latitude : Double
    var longitude : Double
    var name : String

    init() {
        zip = "54911"
        latitude = 44.2619
        longitude = -88.4154
        name = "Appleton"
    }

    init(zip z:String,latitude lat:Double,longitude lo:Double,name n:String) {
        zip = z
        latitude = lat
        longitude = lo
        name = n
    }
}

There is also a LocationInfo class. This class has two key responsibilities: backing up the location information to a local file, and handling any communication with the web service that deals with location information:

import Foundation
import SwiftUI

struct GeoLocation : Codable {
    let zip : String
    let name : String
    let lat : Double
    let lon : Double
    let country : String
}

class LocationInfo : ObservableObject {
    @Published var location : Location

    let itemArchiveURL: URL = {
        let documentsDirectories =
            FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        let documentDirectory = documentsDirectories.first!
        return documentDirectory.appendingPathComponent("location.plist")
    }()

    init() {
        do {
            let data = try Data(contentsOf: itemArchiveURL)
            let unarchiver = PropertyListDecoder()
            let loc = try unarchiver.decode(Location.self, from: data)
            location =  loc
        } catch {
            location = Location()
        }
    }

    func saveChanges() {
        let encoder = PropertyListEncoder()
        let data = try! encoder.encode(location)
        try! data.write(to: itemArchiveURL)
    }

    func searchLocation(code : String!) async {
        if let zip = code {
            let key = "<Your api key here>"
            let urlStr = "https://api.openweathermap.org/geo/1.0/zip?zip=\(zip),US&appid=\(key)"
            guard let url = URL(string: urlStr) else { return }
            do {
                let (data, response) = try await URLSession.shared.data(from: url)
                guard (response as! HTTPURLResponse).statusCode == 200 else { return }
                let result = try JSONDecoder().decode(GeoLocation.self, from: data)
                DispatchQueue.main.async {
                    self.location = Location(zip:zip,latitude:result.lat,longitude:result.lon,name:result.name)
                    self.saveChanges()
                }
            } catch {

            }
        }
    }

This class is essentially the owner for the app's Location information. This class takes the responsibility for saving the Location data to a local file and then loading that information from the file when the app starts back up again.

The searchLocation method handles looking up locations by ZIP code on the server. This method takes the text form of a ZIP code as its input and then runs a GET request on the openweathermap.org server to look up location information for that ZIP code. I will discuss the code in this method in more detail in the section on asynchronous code.

Another important feature of the LocationInfo class is the use of the ObservableObject/@Published combination. Note here that I have annotated the location property with the @Published property modifer. This annotation is similar to the @State annotation that we saw in earlier examples. Classes that contain @Published properties have to implement the ObservableObject protocol. Any view that makes use of an observable object will get recomputed as soon as any @Published property in that ObservableObject gets updated.

Using async and await to simplify asynchronous tasks

We have seen in earlier examples that we can use a URLSession.shared.dataTask() to send requests to a remote server and wait for a reply to come back. One somewhat clunky aspect of using dataTask is the requirement to provide a callback that will get executed when the response come back from the server.

An alternative to using a dataTask is to use the await keyword in combination with the URLSession.shared.data() method. We use the await keyword in Swift to call code that may take a while to complete. Using this keyword means that we are willing to wait until the background task that we are about to launch is complete. As soon as we use the await feature in a method we are also obligated to attach the async keyword to that method. This signals to Swift that the method in question contains code that will carry out an asynchronous operation, and that any code that calls this method must take special care to run this method in a background thread. Later in these lecture notes you will see code that calls the searchLocation() method correctly.

One advantage of using the await keyword is that it allows us to say that we are willing to sit and wait until the method we have called is done and has returned a result to us. This allows us to receive any information returned from the method and then run code after that method call that will process any results we received. All in all this is a cleaner alternative to using a callback closure to handle the results.

There is one final subtle aspect of the searchLocation() method to pay attention to here. When we get Location information back from the server we will want to update the location property. Since this property has been marked @Published updating it will have consequences for our app's user interface. For this reason we need to put the code that updates location in a closure and arrange for that closure to run in the user interface thread.

The main view

The main view in the app displays a weather forecast for the current location. Here is the full code for this view:

struct ContentView: View {
    @State var forecast : Forecast!
    let formatter = DateFormatter()
    @EnvironmentObject private var lc : LocationInfo

    var body: some View {
        NavigationView {
            List {
                Section(header: Text("\(lc.location.name)")) {
                    if let fc = forecast {
                        ForEach(fc.daily) { day in
                            let interval = TimeInterval(day.dt+fc.timezone_offset)
                            let date = Date(timeIntervalSince1970:interval)
                            ForecastView(fd:day,date:date)
                        }
                    } else {
                        Text("Loading...")
                    }
                }
            }.task {
                await getForecast()
            }.navigationTitle("Weather")
             .navigationBarTitleDisplayMode(.inline)
             .navigationBarItems(
                      trailing: NavigationLink("Settings") { WeatherSettings() }
                      )
        }
    }

    func getForecast() async {
        let urlStr = "https://api.openweathermap.org/data/2.5/onecall?lat=\(lc.location.latitude)&lon=\(lc.location.longitude)&APPID=\(Settings.key)&exclude=current,minutely,hourly,alerts&units=imperial"
        guard let url = URL(string: urlStr) else { return }
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            forecast = try JSONDecoder().decode(Forecast.self, from: data)
        } catch {

        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environmentObject(LocationInfo())
    }
}

One of the properties of this view is a Location object. This is one of the data model classes for our application, so we need to make sure that every one of our views has access to an appropriate Location object and that they all use the same Location object. In earlier examples we have handled situations like this by creating the model object in one of our first views and then arranging for that view to pass that object to any other views that we subsequently segue to.

The @EnvironmentObject modifier I have used here implements another way to handle model objects. If we have a model object that we plan to use across multiple views, we can add that model class as a property and annotate it with @EnvironmentObject in each of the views where we will need it. Next, we arrange for the model object to be created and passed to the first view in the app. We do this by modifying the code in the App class that sets up the app's first view:

import SwiftUI

@main
struct UIWeatherApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(LocationInfo())
        }
    }
}

As soon as we arrange for the first view in the app to receive this environment object it will automatically pass that environment object along to any new views it creates. This will save us from having to write the usual segue code to pass that object around from one view to another.

Fetching the forecast data

The code that computes the body for our ContentView class sets up a NavigationController. The first view displayed in that NavigationController is a List that shows the current forecast. Since that List will need data to display, we will need to set up a task to fetch that data. In SwiftUI we do this by chaining a task request after the code that sets up the list:

.task { await getForecast() }

As usual, as task asks us to provide a closure containing the code that we want to run in the task. This code calls an asynchronous method that does the work of getting the forecast from the server.

A custom ForecastView class

We want our main view to display weather information for several days in a list. We start the process of displaying this information by setting up a set of structs to model weather data the server will send us:

struct TempRange : Decodable {
    let day: Double
    let min: Double?
    let max: Double?
    let night: Double
    let eve: Double
    let morn: Double
}

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

struct ForecastDay : Decodable, Identifiable {
    var id : Int { return dt }
    let dt: Int
    let sunrise: Int
    let sunset: Int
    let moonrise: Int
    let moonset: Int
    let moon_phase: Double
    let temp: TempRange
    let feels_like: TempRange
    let pressure: Int
    let humidity: Int
    let dew_point: Double
    let wind_speed: Double
    let wind_deg: Int
    let wind_gust: Double
    let weather: [Weather]
    let clouds: Int
    let pop: Double
    let rain: Double?
    let uvi: Double
}

struct Forecast : Decodable {
    let lat : Double
    let lon : Double
    let timezone: String
    let timezone_offset : Int
    let daily : [ForecastDay]
}

The forecast property in the ContentView class will store the forecast data we receive. In that Forecast object there will be an array of ForecastDay objects. It is this array that the List will display.

As usual, we will use a ForEach() construct in the List to handle setting up the views in the list. In a typical ForEach() construct we make a view for each data item we need to display. In previous examples I have used built-in view types such as Text() for this purpose. For this app I needed to display more complex data for each item, so I decided to create my own view class to display the weather data. Here now is the code for that class:

import SwiftUI

struct ForecastView : View {
    var fd : ForecastDay
    var date : Date
    var body : some View {
        HStack {
            VStack(alignment: .leading) {
                Text(date,style:.date)
                Text("High \(Int(fd.temp.max!)) Low \(Int(fd.temp.min!))")
            }
            Image(systemName: iconName(fd.weather[0].icon)).font(.system(size:32)).padding()
        }
    }

    func iconName(_ icon : String) -> String {
        switch icon {
        case "01d":
            return "sun.max"
        case "02d":
            return "cloud.sun"
        case "03d":
            return "cloud"
        case "04d":
            return "cloud.fill"
        case "09d":
            return "cloud.rain"
        case "10d":
            return "cloud.sun.rain"
        case "11d":
            return "cloud.bolt"
        case "13d":
            return "cloud.snow"
        case "50d":
            return "cloud.fog"
        default:
            return "sun.max"
        }
    }
}

One special feature here is the use of icons. The weather data that the service sends us includes icon codes for each forecast day we get. I set up a helper function that translates those openweathermap icon codes to iOS icon names that we can use in a SwiftUI Image() view.

The location settings view

The app includes a second view where the user can enter a ZIP code to change their location.

Here is the code for the settings view:

import SwiftUI

struct WeatherSettings : View {
    @EnvironmentObject private var lc : LocationInfo
    @State var zip : String = ""

    var body: some View {
        VStack(alignment: .leading) {
            HStack {
                Text("ZIP Code")
                TextField("",text:$zip).keyboardType(.numberPad).textFieldStyle(.roundedBorder)
            }
            Button("Search"){
                Task {
                    let zipPattern = #"^\d{5}$"#
                    let result = zip.range(
                        of: zipPattern,
                        options: .regularExpression
                    )
                    if result != nil {
                        await lc.searchLocation(code: zip)
                    }
                }
            }.buttonStyle(.bordered).padding()
            Text("Latitude: \(lc.location.latitude)")
            Text("Longitude: \(lc.location.longitude)")
            Text("Name: \(lc.location.name)")
        }
    }
}

Like the first view, this view stores a LocationInfo object in a property with the @EnvironmentObject property modifier. This property will get set up for us automatically, when the first view navigates us to this view.

The most interesting part of this view is the code for the search button. When the user clicks this button we will want to call the LocationInfo's searchLocation() method. Since that method does the location search by sending a GET request to the web service, we had to declare that to be an async method. We can not call async methods directly in the action code for a button, so we have to wrap this code in a Task that will run the async method in the background. Since the LocationInfo class is also an ObservableObject containing a @Published property that searchLocation() will update, calling searchLocation() will result in this settings view being rebuilt. When the view gets rebuilt it will display the new location information in the view.

Since the first view also uses the same LocationInfo object that we are using, our update will also force the first view to be rebuilt: this will show the weather forecast for the new location.