Home > Software design >  How to update isLoading state from within URLSession.dataTask() completion handler in Swift?
How to update isLoading state from within URLSession.dataTask() completion handler in Swift?

Time:01-27

I created a view (called AddressInputView) in Swift which should do the following:

  1. Get an address from user input
  2. When user hits submit, start ProgressView animation and send the address to backend
  3. Once the call has returned, switch to a ResultView and show results

My problem is that once the user hits submit, then the view switches to the ResultView immediately without waiting for the API call to return. Therefore, the ProgressView animation is only visible for a split second.

This is my code:

AddressInputView

struct AddressInputView: View {
    @State var buttonSelected = false
    @State var radius = 10_000 // In meters
    @State var isLoading = false
    @State private var address: String = ""
    @State private var results: [Result] = []

    func onSubmit() {
        if !address.isEmpty {
            fetch()
        }
    }

    func fetch() {
        results.removeAll()
        isLoading = true

        let backendUrl = Bundle.main.object(forInfoDictionaryKey: "BACKEND_URL") as? String ?? ""

        let escapedAddress = address.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""
        let params = "address=\(escapedAddress)&radius=\(radius)"
        let fullUrl = "\(backendUrl)/results?\(params)"

        var request = URLRequest(url: URL(string: fullUrl)!)
        request.httpMethod = "GET"

        let session = URLSession.shared
        let task = session.dataTask(with: request, completionHandler: { data, _, _ in
            if data != nil {
                do {
                    let serviceResponse = try JSONDecoder().decode(ResultsServiceResponse.self, from: data!)
                    self.results = serviceResponse.results
                } catch let jsonError as NSError {
                    print("JSON decode failed: ", String(describing: jsonError))
                }
            }
            isLoading = false
        })

        buttonSelected = true
        task.resume()
    }

    var body: some View {
        NavigationStack {
            if isLoading {
                ProgressView()
            } else {
                VStack {
                    TextField(
                        "",
                        text: $address,
                        prompt: Text("Search address").foregroundColor(.gray)
                    )
                    .onSubmit {
                        onSubmit()
                    }

                    Button(action: onSubmit) {
                        Text("Submit")
                    }
                    .navigationDestination(
                        isPresented: $buttonSelected,
                        destination: { ResultView(
                            address: $address,
                            results: $results
                        )
                        }
                    )
                }
            }
        }
    }
}

So, I tried to move buttonSelected = true right next to isLoading = false within the completion handler for session.dataTask but if I do that ResultView won't be shown. Could it be that state updates are not possible from within completionHandler? If yes, why is that so and what's the fix?

Main Question: How can I change the code above so that the ResultView won't be shown until the API call has finished? (While the API call has not finished yet, I want the ProgressView to be shown).

CodePudding user response:

I think the problem is that the completion handler of URLSession is executed on a background thread. You have to dispatch the UI related API to the main thread.

But I recommend to take advantage of async/await and rather than building the URL with String Interpolation use URLComponents/URLQueryItem. It handles the necessary percent encoding on your behalf

func fetch() {
    results.removeAll()
    isLoading = true

    Task {
        let backendUrlString = Bundle.main.object(forInfoDictionaryKey: "BACKEND_URL") as! String
        var components = URLComponents(string: backendUrlString)!
        components.path = "/results"
        components.queryItems = [
            URLQueryItem(name: "address", value: address),
            URLQueryItem(name: "radius", value: "\(radius)")
        ]
    
        do {
            let (data, _ ) = try await URLSession.shared.data(from: components.url!)
            let serviceResponse = try JSONDecoder().decode(ResultsServiceResponse.self, from: data)
            self.results = serviceResponse.results
            isLoading = false
            buttonSelected = true
        } catch {
            print(error)
            // show something to the user
        }
    }
}

The URLRequest is not needed, GET is the default.
And you can force unwrap the value of the Info.plist dictionary. If it doesn't exist you made a design mistake.

CodePudding user response:

Your fetch() function calls an asynchronous function session.dataTask which returns immediately, before the data task is complete.

The easiest way to resolve this these days is to switch to using async functions, e.g.

func onSubmit() {
    if !address.isEmpty {
        Task {
            do {
                try await fetch()
            } catch {
                print("Error \(error.localizedDescription)")
            }
        }
    }
}

func fetch() async throws {
    results.removeAll()
    isLoading = true

    let backendUrl = Bundle.main.object(forInfoDictionaryKey: "BACKEND_URL") as? String ?? ""

    let escapedAddress = address.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""
    let params = "address=\(escapedAddress)&radius=\(radius)"
    let fullUrl = "\(backendUrl)/results?\(params)"

    var request = URLRequest(url: URL(string: fullUrl)!)
    request.httpMethod = "GET"

    let session = URLSession.shared
    let (data, _) = try await session.data(for: request)
    let serviceResponse = try JSONDecoder().decode(ResultsServiceResponse.self, from: data)
    self.results = serviceResponse.results
    isLoading = false
    buttonSelected = true
}

In the code above, the fetch() func is suspended while session.data(for: request) is called, and only resumes once it's complete.

From the .navigationDestination documentation:

In general, favor binding a path to a navigation stack for programmatic navigation.

so add a @State var path to your view and use this .navigationDestination initialiser:

enum Destination {
    case result
}

@State private var path = NavigationPath()

var body: some View {
    NavigationStack(path: $path) {
        if isLoading {
            ProgressView()
        } else {
            VStack {
                TextField("", text: $address, prompt: Text("Search address").foregroundColor(.gray))
                .onSubmit {
                    onSubmit()
                }
                Button(action: onSubmit) {
                    Text("Submit")
                }
                .navigationDestination(for: Destination.self, destination: { destination in
                    switch destination {
                    case .result:
                        ResultView(address: $address, results: $results)
                    }
                })
            }
        }
    }
}

then at the end of your fetch() func, just set

isLoading = false
path.append(Destination.result)

Example putting it all together

struct Result: Decodable {
    
}

struct ResultsServiceResponse: Decodable {
    let results: [Result]
    
}

struct ResultView: View {
    @Binding var address: String
    @Binding var results: [Result]
    
    var body: some View {
        Text(address)
    }
}

enum Destination {
    case result
}

struct ContentView: View {
    
    @State var radius = 10_000 // In meters
    @State var isLoading = false
    @State private var address: String = ""
    @State private var results: [Result] = []
    
    @State private var path = NavigationPath()
    
    var body: some View {
        NavigationStack(path: $path) {
            if isLoading {
                ProgressView()
            } else {
                VStack {
                    TextField("", text: $address, prompt: Text("Search address").foregroundColor(.gray))
                    .onSubmit {
                        onSubmit()
                    }
                    Button(action: onSubmit) {
                        Text("Submit")
                    }
                    .navigationDestination(for: Destination.self, destination: { destination in
                        switch destination {
                        case .result:
                            ResultView(address: $address, results: $results)
                        }
                    })
                }
            }
        }
    }
    
    func onSubmit() {
        if !address.isEmpty {
            Task {
                do {
                    try await fetch()
                } catch {
                    print("Error \(error.localizedDescription)")
                }
            }
        }
    }
    
    func fetch() async throws {
        results.removeAll()
        isLoading = true
        
        try await Task.sleep(nanoseconds: 2_000_000_000)
        self.results = [Result()]
        isLoading = false
        path.append(Destination.result)
    }
}
  • Related