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.
xxxxxxxxxx
if 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.
xxxxxxxxxx
Text(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.
xxxxxxxxxx
Text("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.
xxxxxxxxxx
TextField("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.
xxxxxxxxxx
struct 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.
xxxxxxxxxx
let 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
@MainActor
func 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 + "/" + postalCode
If 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.
xxxxxxxxxx
let 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 %20
so we can add another modifier to convert this for us.
xxxxxxxxxx
let 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
.
xxxxxxxxxx
guard 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.
xxxxxxxxxx
guard 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.xxxxxxxxxx
do {
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.
xxxxxxxxxx
let 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.
xxxxxxxxxx
if let place = location.places.first {
}
This gives us the information we need now to assign an instance to our @Published
optional locationInfo
property.
xxxxxxxxxx
locationInfo = LocationInfo(placeName: place.placeName,
state: place.state,
longitude: Double(place.longitude) ?? 0,
latitude: Double(place.latitude) ?? 0)
The completed function is as followed:
xxxxxxxxxx
@MainActor
func 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.
xxxxxxxxxx
func 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 Task
await
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.xxxxxxxxxx
Button("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
.
xxxxxxxxxx
if let errorString = locationService.errorString {
Text(errorString)
.foregroundColor(.red)
}
locationInfo
.placeName
and state
and present two Text
views in our VStack
.xxxxxxxxxx
if 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.
xxxxxxxxxx
if 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