The next step is to make the query to the API and decode the resulting JSON.
TextField that has to be bound to some @State property so create this inside of the LocationFinderView.xxxxxxxxxx@State private var code = ""We want this TextField only to be visible to our users if the selectedCountry is not our static Country.none so before the Spacer() in the VStack, present this if clause.
xxxxxxxxxxif selectedCountry != .none { }Before we present an action to fetch the result, let's provide the user with the possible range that they might enter. Remember, this is the range property of the Country object.
Create a Text view using this property as the String.
xxxxxxxxxxText(selectedCountry.range)Below that, because we are still in a VStack, create another Text view that will provide the users with the text for what to enter.
.font to a size of .caption..foregroundColor to .secondary.xxxxxxxxxxText("Postal Code/Zip Range") .font(.caption) .foregroundColor(.secondary)Now create the TextField, using "Code" for the title and bind the text to $code.
Set the .textFieldStyle to .roundedBorder.
Apply a .frame with a width of 100.
xxxxxxxxxxTextField("Code", text: $code) .textFieldStyle(.roundedBorder) .frame(width: 100)
Below this, we can create a Button that will make the API request.
Create a Button with the label "Get Location".
Leave the action empty for now. We will need to create a function in our LocationService class that can make that request on our behalf.
Apply a .buttonStyle of .borderedProminent.
Ensure that the button action is .disabled whenever code.isEmpty meaning we have yet to enter a code.
xButton("Get Location") {
}.buttonStyle(.borderedProminent).disabled(code.isEmpty)
When we receive a result back from the API, we want to decode it to create an instance of our Location type object.
The issue is that there may or may not be any places in the array that match the code that we entered.
Also, what if there is more than one? If that is the case we will pick the first one and extract from it the placeName, state and the latitude and longitude so that we can present the place and state name in a couple of Text views and use the coordinates to present a map.
We could, in LocationService create 4 different @Published properties for each, or, to make it easier to read and deal with, within the LocationService class we can create a Struct that contains those 4 properties
Inside theLocationService class, create a new struct called LocationInfo.
Create 4 properties representing the values that we described above.
Note: Though all 4 of the values coming back in our JSON are strings, we want to use Double values for our longitude and latitude, so we will be able to plot the location on a map later.
xxxxxxxxxxstruct LocationInfo { let placeName: String let state: String let longitude: Double let latitude: Double}Now create a single, @Publised property called locationInfo that is an optional instance of LocationInfo.
xxxxxxxxxx@Published var locationInfo: LocationInfo?Now we need to create a function that will make the call to the API to fetch the location. In order to make that call, we will need to know the baseURL for our API site and that is https://api.zippopotam.us.
Before we create the function, create a constant for the baseURL at the top of the LocationServices class.
xxxxxxxxxxlet baseURL = "https://api.zippopotam.us"The other two pieces of information we will need will be the country and the entered postal code. Both of these pieces of information will come from the entry in our LocationFinderView.
wait for the answer to come back so we will make this an asynchronous function by specifying async.fetchLocation@MainActor.xxxxxxxxxx@MainActorfunc fetchLocation(for countryCode: String, postalCode: String) async { }At this point, your uses may enter characters that are not compatible with a properly formed URL, so rather than crashing the app, we should probably handle errors by presenting an error warning somewhere on the screen. We will do this shortly in our UI by adding a conditional Text field in red, only if there has been an error.
To deal with this, we can, in our LocationServices class, create another optional @Published property that we can update whenever there is an error. This means, it will start out as nil, but when we get an error, we can set it to a String of our choosing.
xxxxxxxxxx@Published var errorString: String?Back to our fetchLocation function then, we can continue by first building our request URL from the base, country code and requested postal code.
xxxxxxxxxx let urlSting = baseURL + "/" + countryCode + "/" + postalCodeIf our users add some extra spaces after the code, we will have a problem so we want to remove any of those spaces so we can do that by grouping that combined string in parenthesis and use a trimmingCharacters modifier.
xxxxxxxxxxlet urlString = (baseURL + "/" + countryCode + "/" + postalCode) .trimmingCharacters(in: .whitespacesAndNewlines)This will trim the white space at the end, but what if the postal code has a space in the middle as you will see for at least one of the country ranges? Spaces in a URL must be replaced with %20so we can add another modifier to convert this for us.
xxxxxxxxxxlet urlString = (baseURL + "/" + countryCode + "/" + postalCode) .trimmingCharacters(in: .whitespacesAndNewlines) .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)As soon as we added that last modifier, our urlString is now an optional value and we can't have that so we will need to do a guard check to unwrap it using a guard let ..... else {} block and in the else statement we can assign an appropriate line of text for our errorString.
xxxxxxxxxxguard let urlString = (baseURL + "/" + countryCode + "/" + postalCode) .trimmingCharacters(in: .whitespacesAndNewlines) .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { errorString = "Invalid Code entered." return}Before we leave this guard check, we can do one more thing within the check and that is to see if that urlString can create a valid URL. If it can't we can use the same errorString, so right before the else statement, we can do another check for this by adding another condition to the guard check by using a , and forming the URL from the urlString which could be optional so this let URL inherits the guard from the first part so this will unwrap it if it is a good URL or set our errorString if if is not and then return from our function without proceeding any further.
xxxxxxxxxxguard let urlString = (baseURL + "/" + countryCode + "/" + postalCode) .trimmingCharacters(in: .whitespacesAndNewlines) .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: urlString) else { errorString = "Invalid Code entered." return}Now that we have a valid url, we can execute a shared URLSession method to fetch the data from that url. This is an asynchronous function that may fail so we need to enclose the request in a do ... catch block.
tuple containing data and a response. If it fails, we can catch the error and assign an appropriate error message to our errorString property.data, so when we assign the results to the tuple, I can use let (data, _)URLSession.shared.data method from that url, we must specify that we try and await the result before we can assign our tuple returned values,errorString to an appropriate line of text.xxxxxxxxxxdo { let (data, _) = try await URLSession.shared.data(from: url)} catch { errorString = "Could not decode returned result"}Now that we have the data, we can try to decode it using a JSONDecoder() decode function to a Location.self object and assign it to a temporary location object. If this fails to decode, it will be caught and the error will be the same as we already have in our catch block.
xxxxxxxxxxlet location = try JSONDecoder().decode(Location.self, from: data)If we made it this far, we have a valid location and it has a places array that may or may not have any places in it. If it does, we only want the first one so we can use an if let to wrap it since the first method will always produce an optional value.
xxxxxxxxxxif let place = location.places.first { }This gives us the information we need now to assign an instance to our @Published optional locationInfo property.
xxxxxxxxxxlocationInfo = LocationInfo(placeName: place.placeName, state: place.state, longitude: Double(place.longitude) ?? 0, latitude: Double(place.latitude) ?? 0)The completed function is as followed:
xxxxxxxxxx@MainActorfunc fetchLocation(for countryCode: String, postalCode: String) async { guard let urlString = (baseURL + "/" + countryCode + "/" + postalCode) .trimmingCharacters(in: .whitespacesAndNewlines) .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: urlString) else { errorString = "Invalid Code entered." return } do { let (data, _) = try await URLSession.shared.data(from: url) let location = try JSONDecoder().decode(Location.self, from: data) if let place = location.places.first { locationInfo = LocationInfo(placeName: place.placeName, state: place.state, longitude: Double(place.longitude) ?? 0, latitude: Double(place.latitude) ?? 0) } } catch { errorString = "Could not decode returned result" }}There is one more small function we need to create.
Each time we choose another country, we want to reset our errorString and locationInfo back to nil.
xxxxxxxxxxfunc reset() { locationInfo = nil errorString = nil}Now that we have this LocationService class pretty much completed, we can return to the LocationFinderView and make the call to our new functions when necessary.
Whenever we change our selection of the country, we want to reset our errorString and locationInfo properties.
At the end of our view struct, after the navigationTitle, we can add a method that will watch for changes of the selectedCountry, This .onChange method will provide us with a selectedCountry value that we "could" use in our code but we won't need it so we can simply use _ instead of assigning it to a variable.
xxxxxxxxxx.onChange(of: selectedCountry) { _ in
}In the body of the trailing closure however, we can call the locationService.reset() function AND we can reset our TextField by setting code to an empty string.
xxxxxxxxxx.onChange(of: selectedCountry) { _ in locationService.reset() code = ""}To make the call to the API, we need to update the action for the "Get Location" function.
LocationService class, we must embed it in an asynchronous unit of work called a Taskawait the result of making the call to the locationService.fetchLocation function passing in the selectedCountry.code for the country code and code for the postalCode.xxxxxxxxxxButton("Get Location") { Task { await locationService.fetchLocation(for: selectedCountry.code, postalCode: code) }}We can use this information then to update our view to present the place name and state.
But first, if there were an error, we can present it.
After the disabled modifier, present a Text view if there is a non nil locationService.errorString and since it is optional, we can check for that and unwrap it using an if let and assign it to a local variable called errorString.
after the .disabled modifier perform the check.
Create a Text view using our now unwrapped errorString.
Set the .foregroundColor to .red.
xxxxxxxxxxif let errorString = locationService.errorString { Text(errorString) .foregroundColor(.red)}locationInfo.placeName and state and present two Text views in our VStack.xxxxxxxxxxif let locationInfo = locationService.locationInfo { Text(locationInfo.placeName) Text(locationInfo.state)}One last thing before we test and before we add a map. When the screen loads or if we fail to generate a locationInfo object, we can display an image on the screen.
Just before the Spacer(), create a conditional check to see if the locationService.locationInfo is still nil.
Display an Image view using the locationFinder asset.
xxxxxxxxxxif locationService.locationInfo == nil { Image("locationFinder")}This can be tested now in the preview or in the simulator.
Swift Concurrency and fetching data from an API is something that you will be doing all the time. I have created a 6 part series on that topic and it is available on the Code With Chris YouTube Channel. https://youtube.com/playlist?list=PLMRqhzcHGw1a4jFHEBitPwCtAgPxWldfy
I have also created an Xcode Playground that you can use to test out not only APIs, but also the decoding of data that is returned. A full video on this topic is available on my Channel at https://youtu.be/-UUyo3bOlys