Display loading screen when fetching data in Swift

Last week, we retrieved weather data from Open Weather API to demonstrate how to parse JSON data. The weather data is retrieved asynchronously, which may result in a delay before the UI is updated. This article shows how to display a progress view while the data is being fetched, to provide feedback that something is happening.



Slow down fetching data

This article builds on Read JSON with codeable in Swift using a free account with Open Weather to call the API for the Current weather data. An API key, under tha account settings, is needed to run this code.

Unfortunately (or fortunately), the Open Weather API is really fast! So a delay of 2 seconds is added to the getCurrentWeather function to better see the effect on the UI when the retrieval of the data is not instantaneous. This demonstrates that nothing seems to happen when a city button is selected. Then after a couple of seconds the weather for the selected city is displayed.

 1    // Need to add your Open 
 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        // TODO: Remove artificial slow down
14        sleep(2)
15        
16        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
17            fatalError("Error retrieving weather data")
18        }
19        
20        return FileLoader.loadJson(data)
21    }

Old data is displayed while fetching weather data

Old data is displayed while fetching weather data



Add loading property to the ViewModel

Add a private boolean variable to the Weather ViewModel to store the loading state. This can be set to false by default and needs to be marked as @Published so that whenever it is changed all views using it will be reloaded. A public readonly property of isLoading that is available to the view is also added. This property is set to true at the start of getting weather for a city and set to false when the weather data has been retrieved and the JSON parsed.

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

The View is updated to check for the isLoading property and a progress view is displayed if the value is true, otherwise the WeatherView is displayed.

 1struct WeatherView3: 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            if weatherVm.isLoading {
24                ProgressView()
25                    .progressViewStyle(CircularProgressViewStyle(tint: .blue))
26            } else {
27                WeatherView(weatherVm: weatherVm)
28            }
29            
30            Spacer()
31        }
32    }
33}

Display progress indicator when fetching weather data
Display progress indicator when fetching weather data



Move the Progress View

The ProgressView is displayed directly below the city buttons and seems too close to the buttons. It is better if the ProgressView is displayed where the weather is displayed, a simple Spacer is added above the ProgressView to address this.

 1    var body: some View {
 2        VStack {
 3            VStack {
 4                Text("Current weather in Cities")
 5                Button("New York") {
 6                    Task {
 7                        await weatherVm.weatherForCity(_: .newyork)
 8                    }
 9                }
10                .buttonStyle(BlueButtonStyle())
11                Button("Cape Town") {
12                    Task {
13                        await weatherVm.weatherForCity(_: .capetown)
14                    }
15                }
16                .buttonStyle(BlueButtonStyle())
17            }
18            .frame(width: 300)
19            
20            if weatherVm.isLoading {
21                Spacer().frame(height:100)
22                ProgressView()
23                    .progressViewStyle(CircularProgressViewStyle(tint: .blue))
24            } else {
25                WeatherView(weatherVm: weatherVm)
26            }
27            
28            Spacer()
29        }
30    }

Display progress indicator lower down the screen
Display progress indicator lower down the screen


Display ProgressView when fetching weather data

Display ProgressView when fetching weather data



Disable buttons when fetching weather data

It is also possible to continually keep tapping the city button and send multiple requests for the weather data without allowing time for the data to load. The same isLoading property can be used to disable the buttons while the data is being retrieved.

 1    var body: some View {
 2        VStack {
 3            VStack {
 4                Text("Current weather in Cities")
 5                Button("New York") {
 6                    Task {
 7                        await weatherVm.weatherForCity(_: .newyork)
 8                    }
 9                }
10                .buttonStyle(BlueButtonStyle())
11                .disabled(weatherVm.isLoading)
12
13                Button("Cape Town") {
14                    Task {
15                        await weatherVm.weatherForCity(_: .capetown)
16                    }
17                }
18                .buttonStyle(BlueButtonStyle())
19                .disabled(weatherVm.isLoading)
20            }
21            .frame(width: 300)
22            
23            if weatherVm.isLoading {
24                Spacer().frame(height:100)
25                ProgressView()
26                    .progressViewStyle(CircularProgressViewStyle(tint: .blue))
27            } else {
28                WeatherView(weatherVm: weatherVm)
29            }
30            
31            Spacer()
32        }
33    }

The style of the buttons is updated to set the color to gray when the button is disabled.

 1struct BlueButtonStyle: ButtonStyle {
 2    @Environment(\.isEnabled) var isEnabled
 3    
 4    func makeBody(configuration: Configuration) -> some View {
 5        configuration.label
 6            .foregroundColor(.white)
 7            .padding(5)
 8            .frame(maxWidth: .infinity)
 9            .background(isEnabled ? Color.blue : Color.gray)
10            .cornerRadius(5)
11            .shadow(color:.black, radius:3, x:3.0, y:3.0)
12            .scaleEffect(configuration.isPressed ? 0.95 : 1.0)
13    }
14}

Disable buttons when fetching weather data
Disable buttons when fetching weather data


Disable buttons when fetching weather data

Disable buttons when fetching weather data




Conclusion

It is great to be able to fetch data in the background and leave the app responsive, however, it is important to display something to the user. This is implemented using a property in the ViewModel. This loading property is set to true when the API is called and set to false when the data has been retrieved and loaded into the model. A progress view can be conditionally displayed on the UI bound to this loading property. Other UI elements, such as butttons can also have behavior bound to the same property. In this way the buttons can be disabled while the data is being fetched to help prevent multiple calls to the API.