Time Zones App Part 2: HTTP Request with The MVVM Design pattern

SwiftUI Nov 29, 2019

This is a continuation of last's week’s article in which we created the user interface for the time zones app. If you haven't read it yet, check it out here. In this part, we will fetch time zones data from a remote api using the MVVM design pattern. You'll learn how to make an http request in a swiftUI app and how to update the UI using the Observables.

Here is what we are going to build:

Get started

If you were following along, i'd suggest you continue from where we left off. However, if you want you can just start a brand new project and use a mock or basic UI to test with, you actually don't need a fancy user interface to follow this article. Get the final project here.

Networking layer

In your root directory, create a Networking folder and inside it add a swift file called WebService.swift, then add the following code inside:

import Foundation


class WebService {
    private var url: String
    
    init(url: String) {
        self.url = url
    }
    
    func get(completion: @escaping((Result<Data, Error>)->())) {
        guard let url = URL(string: self.url) else { return }
        
        URLSession.shared.dataTask(with: url) { (data, response, error) in
            guard let data = data, error == nil else {
                completion(.failure(error!))
                return
            }
            completion(.success(data))
        }.resume()
    }
}

The above code will retrieve remote data from the internet, pretty simple. For production, you should return appropriate errors and status codes.

In the same folder, create another swift file named Parser.swift and put the following code inside:

import Foundation

class Parser<T: Codable> {
    
    private var data: Data
    
    init(_ data: Data) {
        self.data = data
    }
    
    func parse(onCompletion: @escaping(( Result<T, Error>)->())) {
        DispatchQueue(label: "com.liquidcoder", qos: .userInteractive, attributes: .concurrent).async {
            let decoder = JSONDecoder()
            decoder.keyDecodingStrategy = .convertFromSnakeCase
            do{
                let results = try decoder.decode(T.self, from: self.data)
                let result: Result<T, Error> = .success(results)
                onCompletion(result)
                
            }catch let error{
                let result: Result<T, Error> = .failure(error)
                onCompletion(result)
            }
        }
    }
}

The above code will parse the data coming from our WebService, and the entire process will be done in background using the DispatchQueue.

Note: For simplicity purposes, I will not create protocols for those services, but in production you should always code with protocols as much as possible

Now, let’s put those 2 pieces together. In the same folder, add a file named DataProvider.swift which, as its name implies, should provide data to the view model that will be created shortly. Put the following code inside:


import Foundation

class DataProvider<T: Codable> {
    
    private var service: WebService
    
    init(service: WebService) {
        self.service = service
    }
    
    func provide(completion: @escaping((Result<T, Error>)->()))  {
        
        service.get { result in
            switch result{
            case .success(let data):
                let parser = Parser<T>(data)
                parser.parse(onCompletion: { parsedResult in
                    completion(parsedResult)
                })
            case .failure(let error):
                completion(.failure(error))
            }
        }
    }
}

And that’s it for our simple networking layer

Models

In the Models folder, replace the TimeZone.swift content with the following:


struct TimeZone: Identifiable {
    let id: UUID
    let area: String
    let location: String
    let utcOffset: String
    
    init() {
        id = UUID()
        self.area = "No area"
        self.location = "No location"
        self.utcOffset = "00:00"
    }
    
    init(_ area: String,_ location: String,_ utcOffset: String  ) {
        id = UUID()
        self.area = area
        self.location = location
        self.utcOffset = utcOffset
    }
    
    init(from remoteTimeZone: RemoteTimeZone) {
        id = UUID()
        utcOffset = remoteTimeZone.utcOffset
        area = String(remoteTimeZone.timezone.split(separator: "/").first ?? "")
        location =  String(remoteTimeZone.timezone.split(separator: "/").last ?? "")
        
    }
}

Then add 2 new files, the first one being Location.swift with the following code inside:


import Foundation


struct Location: Codable, Identifiable {
    let id: UUID
    let name: String
    let area: String
    
    private enum CodingKeys: String, CodingKey{
        case id = "id"
        case name = "location"
        case area = "area"
    }
}

And the last one RemoteTimeZone.swift with this code inside:

struct RemoteTimeZone: Codable {
    let utcOffset: String
    let utcDatetime: String
    let timezone: String
    let datetime: String
}

ViewModels

The view model will receive data from the data provider then serves it to the views to be displayed to the user. Create a folder named, you guessed it, ViewModels and inside it create a file named TimeZoneViewModel.swift containing the following:

import Foundation

struct TimeZoneViewModel {
        
   static func getTimeZone(for country: String, completion: @escaping((_ timezone: TimeZone)->())) {
        let url = "https://worldtimeapi.org/api/timezone/\(country).json"
        
        let webService = WebService(url: url)
        let provider = DataProvider<RemoteTimeZone>(service: webService)
        provider.provide { result in
            switch result{
            case .success(let response):
                DispatchQueue.main.async {
                    completion(TimeZone(from: response))
                }
                
            case .failure(let error):
                print("\(error)")
            }
        }
    }
}


The above code does the following:

  1. Receives the remote time zones from the data provider
  2. Convert it into a TimeZone type that our views will understand or just print an error if the process failed. (Again in production, errors should handled appropriately)
Note on the api service.
I am using this website https://worldtimeapi.org to get the timezone data. Although it does the job, there’s no way of getting all timezones in a single request; I can only retrieve one location at a time. Shortly we will parse a json file containing all the areas and locations supported by worldtimeapi .

The time payload for a particular location, in this case London, will look like this:

{"week_number":48,"utc_offset":"+00:00","utc_datetime":"2019-11-29T05:54:57.828806+00:00","unixtime":1575006897,"timezone":"Europe/London","raw_offset":0,"dst_until":null,"dst_offset":0,"dst_from":null,"dst":false,"day_of_year":333,"day_of_week":5,"datetime":"2019-11-29T05:54:57.828806+00:00","client_ip":"37.228.229.218","abbreviation":"GMT"}

To get that, we used the following url http://worldtimeapi.org/api/timezone/Europe/London.json where Europe is an area and London a location. With that in mind, the general form of url to get time zone details for a particular area and location will be http://worldtimeapi.org/api/timezone/area/location.json.

Now open the completed project and copy the areaslocations.json into your project. Then in the ViewModels folder, add a file named AreaLocationViewModel.swift and paste the following code inside:

import Foundation

typealias AreaLocation = [String: [Location]]


class AreaLocationViewModel: ObservableObject {
    
    
    @Published
    var areaLocationMap = AreaLocation()
    
    init() {
            self.getCountries()
    }
    
    func getCountries() {
        if let path = Bundle.main.path(forResource: "areaslocations", ofType: "json") {
            do {
                let fileUrl = URL(fileURLWithPath: path)
//                 Getting data from JSON file using the file URL
                let data = try Data(contentsOf: fileUrl, options: .mappedIfSafe)
                
                let parser = Parser<AreaLocation>(data)
                parser.parse(onCompletion:{ result in
                    switch result {
                    case .success(let areaLocationMap ):
                        DispatchQueue.main.asyncAfter(deadline:  .now() + 0.5) {
                            self.areaLocationMap = areaLocationMap
                        }
                    case .failure(let error):
                        print(error)
                    }
                })
                
            } catch let e{
                print(e)
            }
        }
    }

The above class conforms to the ObservableObject protocol in order for every view that’s using it to be notified whenever the areaLocationMap property with the @Publisher attribute state changes. We also set the property on the main thread using GCD (Grand Central Dispatch) because the parsing process is done on a background thread. We are using asyncAfter(deadline:  .now() + 0.5) instead of async for this reason: if we update the UI at the same time as the areaLocationMap is being set, the UI will block and we won’t be able to see the animation that we set to the view that will display this data. Basically, the UI will be notified while the animation is running which will cause it to block.

New views and update existing views

We will first create the view that will display the area location list and will name it AreaLocationView.swift. Go ahead and create that view inside the Views folder. Add the following properties at the top of the file:

@ObservedObject private var areaLocationModel = AreaLocationViewModel()
@Binding var timezones: [TimeZone]
@State var isLoading = false

The first property is preceded by the @ObservedObject attribute in order for it to notify interested views. You already know the remaining attribute if you’ve been reading my articles. Then in the body block, add the following:

           if !areaLocationModel.areaLocationMap.isEmpty {
           return AnyView(VStack {
                Text("CHOOSE A LOCATION")
                    .font(.title)
                    .frame(maxWidth: .infinity)
                    .padding()


                ScrollView {
                    ForEach(Array(self.areaLocationModel.areaLocationMap.keys), id: \.self) { area in
                        Section(header: HStack {
                            Text("\(area)")
                                .padding(.horizontal)
                                .padding(.vertical, 10)
                                .frame(maxWidth: .infinity, alignment: .leading)
                                .font(.system(size: 25, weight: .bold))
                                .foregroundColor(Color.white)

                        }.background(Color.white.opacity(0.2))){
                            ForEach(self.areaLocationModel.areaLocationMap[area] ?? []){ location in

                                LocationRow(location: location, isLoading: self.$isLoading, onSelect: { selectedLocation in
                                    self.isLoading = true
                                                    TimeZoneViewModel.getTimeZone(for: "\(selectedLocation.area)/\(selectedLocation.name)", completion: { timeZone in
                                                        self.timezones.append(timeZone)
                                                        self.isLoading = false
                                                    })
                                }) { deselectedLocation in
                                    self.timezones.removeAll {  $0.area == deselectedLocation.area &&  $0.location == deselectedLocation.name }
                                }
                            }
                        }
                    }
                }
            })
        } else {
          return AnyView( VStack{
            Text("Loading...").foregroundColor(Color.white).bold()
            })
        }

That block of code may look intimidating, but it’s actually pretty simple and does the following:

  1. Checks whether areaLocationModel.areaLocationMap has content and shows the list of locations otherwise shows a loading indicator.
  2. Creates a scrollview that contains a list of location grouped by the keys of the areaLocationMap.
  3. For each location, we create a location row view. Right now your code does not have LocationRow defined,but you’ll create that shortly.
  4. The LocationRow will have an onSelect and an onDeselect that is used to respectively add and remove a selected location from the timezones array. When one selects a location, we immediately perform an http request to retrieve the timezone for that particular location.
Note: We return AnyView because of the condition otherwise the above code would produce the following error Function declares an opaque return type, but has no return statements in its body from which to infer an underlying type. It means the compiler is confused about the type to use.

Now below the AreaLocationView, add the following:

}

struct LocationRow: View {
   
    var location: Location
    @Binding var isLoading: Bool
    var onSelect: ((_ location: Location) -> ()) =  {_ in }
    var onDeselect: ((_ location: Location) -> ()) =  {_ in }
    @State private var isSelected = false
    
    var body: some View {
        HStack {
            Text("\(location.name)").foregroundColor( isSelected ? Color.yellow : Color.white  )
            Spacer()
            Image(systemName: isSelected ? "circle.fill" : "circle")
                .imageScale(.large)
                .foregroundColor(isLoading ? Color.gray : Color.white)
        }.padding().background(Color.clear).onTapGesture {
            if !self.isLoading{
                self.isSelected.toggle()
                if self.isSelected {
                    self.onSelect(self.location)
                } else {
                    self.onDeselect(self.location)
                }
            }
        }
    }
}

We will only perform the selection or deselection if isLoading is false. You will see why we are using is loading later on.

Now open ContentView.swift and add the following at the top of the file:

@State private var areas = [Location]()
@State private var timezones = [TimeZone]()

And in the overlay replace the following:

VStack {
          Text("Hello world")
       }

With this:

AreaLocationView( timezones: self.$timezones )

Now if you click the add button, you should see the a loading text, then the list of locations will appear. One could replace the loading text with an actual loading indicator with a beautiful animation. If you go in the AreaLocationViewModel.swift file and remove the 0.5 when setting areaLocationMap, you will find that when you click the add button, the sheet does not show the entire animation and blocks for a fraction of a second, this is because the UI want to update while the animation is underway.

We will need to make some changes to AddedTimeZones and SavedTimeZones , add the following property in both views:

 @Binding var timezones: [TimeZone]

Then, replace the calls of those 2 with the following:

    if isExpanded{
                AddedTimeZones(timezones: self.$timezones).transition(.move(edge: .trailing))
            }

            if !isExpanded{
                SavedTimeZones(timezones:  self.$timezones).transition(.move(edge: .leading))
            }

In their respective ForEach parameter, replace TimeZone.data() with timezones.reversed(). Now every time, you select one location you will see a new time zone item pops up at the top and when you close the sheet, the savedTimezone section will be filled with existing and new timezones.

Check the result:

In the next and last but not least article, we will finish this by persisting the saveTimezones to CoreData. That’s it for this part, subscribe for more and please feel free to share this article. Happy coding!!!

John K

I am a software developer and code enthusiast. Do you want to work with me, have a suggestion or a request? Feel free to contact me at [email protected] or https://twitter.com/liquidcoder