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
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
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 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
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.