I created a view (called AddressInputView
) in Swift which should do the following:
- Get an address from user input
- When user hits submit, start ProgressView animation and send the address to backend
- 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)
}
}