Read JSON with codeable in Swift

Reading JSON data from local files and url is now much easier with codeable in Swift 5. This article shows how to load JSON data from a local file as well as from the open weather data api.



Open Weather

Open Weather provide a range of weather-related products accessed through API calls. There is a free tier available that will allow us to retrieve the current weather through the api for Current weather data.

Different APIs available from openweathermap
Different APIs available from openweathermap



Create local JSON file

An empty file is added to the project with the name of the weatherData.json. We are going to focus on the Current Weather data. The sample JSON data from the current whether API call is copied to the JSON file.

Create an empty file to contain JSON data
Create an empty file to contain JSON data

 1{
 2    "coord": {
 3        "lon": -122.08,
 4        "lat": 37.39
 5    },
 6    "weather": [
 7        {
 8            "id": 800,
 9            "main": "Clear",
10            "description": "clear sky",
11            "icon": "01d"
12        }
13    ],
14    "base": "stations",
15    "main": {
16        "temp": 282.55,
17        "feels_like": 281.86,
18        "temp_min": 280.37,
19        "temp_max": 284.26,
20        "pressure": 1023,
21        "humidity": 100,
22        "sea_level": 100
23    },
24    "visibility": 16093,
25    "wind": {
26        "speed": 1.5,
27        "deg": 350
28    },
29    "clouds": {
30        "all": 1
31    },
32    "dt": 1560350645,
33    "sys": {
34        "type": 1,
35        "id": 5122,
36        "message": 0.0139,
37        "country": "US",
38        "sunrise": 1560343627,
39        "sunset": 1560396563
40    },
41    "timezone": -25200,
42    "id": 420006353,
43    "name": "Mountain View",
44    "cod": 200
45}


Define Swift object for the JSON data

It is helpful to define Swift Structs to contain the main data from the JSON objects. It is not necessary to define elements for all of the items in the JSON file.

 1struct WeatherRawData : Codable {
 2    var name: String
 3    var timezone: Double
 4    var weather: [WeatherData]
 5    var main: MainData
 6    var wind: WindData
 7    var clouds: CloudsData
 8    
 9    struct WeatherData: Codable {
10        var id: Double
11        var main: String
12        var description: String
13        var icon: String
14    }
15    
16    struct MainData: Codable {
17        var temp: Double
18        var feels_like: Double
19        var temp_min: Double
20        var temp_max: Double
21        var pressure: Double
22        var humidity: Double
23    }
24    
25    struct WindData: Codable {
26        var speed: Double
27        var deg: Double
28    }
29    
30    struct CloudsData: Codable {
31        var all: Double
32    }
33}


Load the JSON data from the file

The JSON data is loaded from the file when the application is launched. A file loader class with a static function to load the JSON data is added to the app. Loading the JSON file is split into two functions; the first locates the file and loads the contents into a Data object; the second uses JSONDecoder to decode the JSON data into a WeatherRawData object.

 1class FileLoader {
 2    
 3    static func readLocalFile(_ filename: String) -> Data? {
 4        guard let file = Bundle.main.path(forResource: filename, ofType: "json")
 5            else {
 6                fatalError("Unable to locate file \"\(filename)\" in main bundle.")
 7        }
 8        
 9        do {
10            return try String(contentsOfFile: file).data(using: .utf8)
11        } catch {
12            fatalError("Unable to load \"\(filename)\" from main bundle:\n\(error)")
13        }
14    }
15    
16    
17    static func loadJson(_ data: Data) -> WeatherRawData {
18        do {
19            return try JSONDecoder().decode(WeatherRawData.self, from: data)
20        } catch {
21            fatalError("Unable to decode  \"\(data)\" as \(WeatherRawData.self):\n\(error)")
22        }
23    }
24}

The print statement is used to ensure the file is loading and the JSON is being decoded correctly into WeatherRawData.

 1struct ContentView: View {
 2    init() {
 3        if let data = FileLoader.readLocalFile("weatherData")
 4        {
 5            let rawWeather = FileLoader.loadJson(data)
 6            print("rawWeather = \(rawWeather)") 
 7        }
 8    }
 9    
10    var body: some View {
11        Text("Hello, World!")
12    }
13}

Using JSONDecoder to load a JSON file
Using JSONDecoder to load a JSON file



Create Weather Model and ViewModel

It might be tempting to use the raw weather data directly in a Swift view, but this would lead to tightly coupled code that would be brittle. It is better to define a separate model for the Weather containing the data that is relevant to the App.

Weather Model

 1struct WeatherModel {
 2    var locationName: String
 3    var weatherName: String
 4    var description: String
 5    var temperature: Double
 6    var timeOffUtc: Double //timezone Shift in seconds from UTC
 7    
 8    init (data: WeatherRawData) {
 9        locationName = data.name
10        weatherName = data.weather.first!.main
11        description = data.weather.first!.description
12        temperature = data.main.temp
13        timeOffUtc = data.timezone
14    }
15}

Weather ViewModel

A weather ViewModel is added to separate the View from the Model. It is defined as a class because it has to conform to ObservableObject protocol, which allows the view in SwiftUI to bind to the ViewModel. The weatherModel property is marked as @Published so that whenever there are any changes to the model, all views using that object will be reloaded to reflect those changes. There is more detail on MVVM in MVVM in SwiftUI.

 1class WeatherViewModel: ObservableObject {
 2    @Published private var weatherModel: WeatherModel
 3    
 4    init() {
 5        guard let data = FileLoader.readLocalFile("weatherData")
 6            else {
 7                fatalError("Unable to locate file \"weatherData.json\" in main bundle.")
 8        }
 9        
10        let rawWeather = FileLoader.loadJson(data)
11        weatherModel = WeatherModel(data: rawWeather)
12    }
13    
14    var location: String {
15        get { weatherModel.locationName }
16    }
17    
18    var weatherMain: String {
19        get { weatherModel.weatherName }
20    }
21    
22    var description: String {
23        get { weatherModel.description }
24    }
25    
26    var temperature: Double {
27        get { weatherModel.temperature }
28    }
29    
30    var locationTime: String {
31        get {
32            let utcDateFormatter = DateFormatter()
33            utcDateFormatter.timeZone = TimeZone(abbreviation: "UTC")
34            utcDateFormatter.timeStyle = .medium
35            let now = Date().addingTimeInterval(weatherModel.timeOffUtc)
36            let dateString = utcDateFormatter.string(from: now)
37            return dateString
38        }
39    }
40}

Weather View

The View has a property for the ViewModel and a new ViewModel is instantiated when the App is launched. The weatherVm property is marked with the @ObservedObject property wrapper, so the view can be notified when the state of the object has changed. The view simply displays some of the weather data loaded from the JSON file.

 1struct WeatherView: View {
 2    @ObservedObject var weatherVm: WeatherViewModel
 3    
 4    var body: some View {
 5        List {
 6            HStack {
 7                Text("Location")
 8                    .frame(width:100, alignment: .trailing).padding(.horizontal)
 9                Text(weatherVm.location)
10            }
11            HStack {
12                Text("Weather")
13                    .frame(width:100, alignment: .trailing).padding(.horizontal)
14                Text(weatherVm.weatherMain)
15            }
16            HStack {
17                Text("Description")
18                    .frame(width:100, alignment: .trailing).padding(.horizontal)
19                Text(weatherVm.description)
20            }
21            HStack {
22                Text("Temperature")
23                    .frame(width:100, alignment: .trailing).padding(.horizontal)
24                Text("\(weatherVm.temperature)°K")
25            }
26            HStack {
27                Text("Time")
28                    .frame(width:100, alignment: .trailing).padding(.horizontal)
29                Text(weatherVm.locationTime)
30            }
31        }
32    }
33}

Display Weather data in SwiftUI loaded from a JSON file
Display Weather data in SwiftUI loaded from a JSON file



Get Weather from Open Weather API

Define a WeatherService class with a static function to call the Current weather data API and retrieve the weather data based on GPS coordinates. An API key is needed from Open Weather, which is located in the account section. The data returned from the API call is the same format as the sample data, so the loadJson function is reused to decode the JSON data into WeatherRawData.

 1class WeatherService {    
 2    static func getCurrentWeather(latitude: CLLocationDegrees,
 3                                  longitude: CLLocationDegrees) async throws -> WeatherRawData {
 4        // Set the API with the appId for OpenWeather
 5        guard let url = URL(string: "https://api.openweathermap.org/data/2.5/weather?lat=\(latitude)&lon=\(longitude)&units=metric&appid=\("xxx WEATHER API KEY xxx")") else {
 6            fatalError("Missing url")
 7        }
 8        
 9        // Call the API asynchronously and wait for the response
10        let urlRequest = URLRequest(url: url)
11        let (data, response) = try await URLSession.shared.data(for: urlRequest)
12        
13        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
14            fatalError("Error retrieving weather data")
15        }
16        
17        return FileLoader.loadJson(data)
18    }
19}

Weather ViewModel

Update ViewModel to hold the current city and call the WeatherService.getCurrentWeather when the city is selected in the View. Add an enum for a number of cities and a function to return GPS coordinates for each city. This could possibly be changed to a dictionary or some other structure.

 1class WeatherViewModel: ObservableObject {
 2    @Published private var weatherModel: WeatherModel
 3    private var _city: String
 4    
 5    init() {
 6        guard let data = FileLoader.readLocalFile("weatherData")
 7            else {
 8                fatalError("Unable to locate file \"weatherData.json\" in main bundle.")
 9        }
10        
11        let rawWeather = FileLoader.loadJson(data)
12        weatherModel = WeatherModel(data: rawWeather)
13        _city = "not set"
14    }
15    
16    var location: String {
17        get { weatherModel.locationName }
18    }
19    
20    var weatherMain: String {
21        get { weatherModel.weatherName }
22    }
23    
24    var description: String {
25        get { weatherModel.description }
26    }
27    
28    var temperature: Double {
29        get { weatherModel.temperature }
30    }
31    
32    var locationTime: String {
33        get {
34            let utcDateFormatter = DateFormatter()
35            utcDateFormatter.timeZone = TimeZone(abbreviation: "UTC")
36            utcDateFormatter.timeStyle = .medium
37            let now = Date().addingTimeInterval(weatherModel.timeOffUtc)
38            let dateString = utcDateFormatter.string(from: now)
39            return dateString
40        }
41    }
42    
43    var city: String {
44        get { return _city}
45    }
46    
47    @MainActor
48    func weatherForCity(_ c:City) async {
49        _city = c.rawValue
50        let (lat, lon) = coordinates(for: c)
51        
52        do {
53            let rawWeather = try await WeatherService.getCurrentWeather(latitude: lat, longitude: lon)
54            weatherModel = WeatherModel(data: rawWeather)
55        }
56        catch {
57            print("Error fetching weather with '\(c.rawValue)' City:\n \(error)")
58        }
59    }
60    
61    private func coordinates(for city: City) -> (Double, Double) {
62        switch city {
63        case .newyork:
64            return (40.749939623101724, -73.98584035140507)
65        case .london:
66            return  (51.48403374752388, -0.0059268752163408114)
67        case .paris:
68            return  (48.8619958275662, 2.294848578874564)
69        case .vancouver:
70            return  (49.2791749376975, -123.10359944424778)
71        case .capetown:
72            return  (-33.96475307519853, 18.417554193804826)
73        case .sydney:
74            return  (-33.85657055055687, 151.21537180010895)
75        }
76    }
77}
78
79enum City: String, CaseIterable, Identifiable {
80    var id: Self { self }
81    case newyork = "New York"
82    case london = "London"
83    case paris = "Paris"
84    case vancouver = "Vancouver"
85    case capetown = "Cape Town"
86    case sydney = "Sydney"
87}

Weather View

Add another view to display a number of buttons for different cities and the existing WeatherView. The action on the city buttons needs to call the weatherForCity function in the ViewModel asynchronously when the city is selected.

 1struct WeatherView2: View {
 2    @ObservedObject var weatherVm: WeatherViewModel
 3    
 4    var body: some View {
 5        VStack {
 6            VStack {
 7            Text("Current weather in Cities")
 8            Button("New York") {
 9                Task {
10                    await weatherVm.weatherForCity(_: .newyork)
11                }
12            }
13            .buttonStyle(BlueButtonStyle())
14            Button("Cape Town") {
15                Task {
16                    await weatherVm.weatherForCity(_: .capetown)
17                }
18            }
19            .buttonStyle(BlueButtonStyle())
20            }
21            .frame(width: 300)
22
23            WeatherView(weatherVm: weatherVm)
24        }
25    }
26}

Display Weather data in SwiftUI loaded from a JSON data from API
Display Weather data in SwiftUI loaded from a JSON data from API


Display Weather from two cities

Display Weather from two cities




Conclusion

Parsing JSON content and loading into a Swift objects is straight forward using JSONDecoder. In this article, weather data was loaded, first from a local file in the app bundle, and then from a call to Open Weather API. Parsing the JSON data is the same whether the data originates from a local file or from a web API. Some of the weather data is simply presented in a list, this could be improved by displaying appropriate icons or images based on the weather conditions.