skip to Main Content

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

2

Answers


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

    Login or Signup to reply.
  2. 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)
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search