Chapters

Hide chapters

SwiftUI Apprentice

Third Edition · iOS 18 · Swift 5.9 · Xcode 16.2

Section I: Your First App: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your Second App: Cards

Section 2: 9 chapters
Show chapters Hide chapters

24. Downloading Data
Written by Audrey Tam

Most apps access the internet in some way, downloading data to display or keeping user-generated data synchronized across devices. TheMet app needs to create and send HTTP requests and process HTTP responses. Downloaded data is usually in JSON format, which your app needs to decode into its data model.

If your app downloads data from your own server, you might be able to ensure the JSON structure matches your app’s data model. But TheMet needs to work with the metmuseum.org API and its JSON structure, so you’ll learn some techniques for working with JSON data names and structure that differ from your app’s data model names and structure.

Getting Started

Open the DownloadingData playground in the starter folder. If the editor window is blank, show the Project navigator (Command-1) and select DownloadingData there.

Open DownloadingData playground.
Open DownloadingData playground.

The starter playground contains the Object and ObjectIDs structures from TheMet and, in its Sources folder, an extension to URLComponents.

Playgrounds are useful for exploring and working out code before moving it into your app. You can quickly inspect values produced by code and methods without needing to build a user interface or search through a lot of debug console messages.

URLSession

URLSession is Apple’s framework for HTTP messages. Apple’s documentation page includes this note:

Note: The URLSession API involves many different classes that work together in a fairly complex way which may not be obvious if you read the reference documentation by itself.

To send a request to a server, you need to perform several steps:

  1. Specify a URLSession to coordinate data-transfer tasks. For the simple download tasks in TheMet, you’ll use the built-in shared session, which provides a reasonable default behavior.

  2. Create a URL from a String like https://metmuseum.org. This could fail — for example, if the string is empty.

  3. Create a URLRequest from this URL: This object contains the HTTP method and headers and other request properties.

  4. Send the URLRequest in the shared session and await a Data instance and a URLResponse object.

  5. Decode the data into your app’s data model: Most REST APIs send JSON data. You’ll use a JSONDecoder to decode this into your data model. The code is much simpler than what you did in Chapter 19, “Saving Files” because TheMet needs only a few properties from the metmuseum.org API, and its JSON names and structures are a good match for TheMet’s data model.

Note: URLSession and the broader topic of networking have their own video courses: Beginning Networking with URLSession and Advanced Networking with URLSession.

Asynchronous Methods

Most URLSession methods involve network communication, so you can’t predict how long they’ll take to complete. In the meantime, the system must continue to interact with the user. To make this possible, URLSession methods are asynchronous: They dispatch their work onto another queue and immediately return control to the main queue, so it can respond to user interface events. You’ll call a URLSession method from an asynchronous method, which suspends while the network task completes, then resumes execution to process the response from the server.

Note: Learn more about asynchronous methods and concurrency in our book Modern Concurrency in Swift and its companion video courses Modern Concurrency: Getting Started and Modern Concurrency: Beyond the Basics.

Creating a REST Request URL

A REST request URL often includes query parameters. Here’s one from the metmuseum.org’s API:

https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts|Silk|Bedcovers&q=quilt

This URL lists query parameter names and values after the ? separator. The query parameter medium=Quilts|Silk|Bedcovers matches any object whose medium is Quilts or Silk or Bedcovers. You can send the request just like this in Postman or Safari but, to send it from your app, the | character must be URL-encoded as %7C:

https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts%7CSilk%7CBedcovers&q=quilt

You certainly don’t want to do the URL-encoding yourself! Fortunately, you can hand this work over to URLComponents and URLQueryItem. You’ll use these in this playground to create a flexible approach to composing REST requests, so you can easily change query parameter values.

URLComponents

URLComponents enables you to construct a URL from its parts and, also, to access the parts of a URL. Components include scheme, host, port, path, query and queryItems. URL itself gives you access to URL components like lastPathComponent.

➤ Add this code to the playground, below the two structs:

let baseURLString = "https://collectionapi.metmuseum.org/public/collection/v1/"
var urlComponents = URLComponents(
  string: baseURLString + "search")!
urlComponents.queryItems = [
  URLQueryItem(name: "medium", value: "Quilts|Silk|Bedcovers"),
  URLQueryItem(name: "q", value: "quilt")
]
urlComponents.url
urlComponents.url?.absoluteString

You set the URL string for the API’s base endpoint and add the search endpoint to create a URLComponents instance. Then, you create an array of URLQueryItem values. The URLQueryItem parameters are the parameter names and values in the sample request.

The last line displays the final URL string in the sidebar. The line above it displays the final URL. What’s the difference? Time to find out!

Note: In a playground, you can write an expression on its own line to display its value.

➤ Click the Execute Playground arrow on the last line number or at the bottom of the playground:

Execute-Playground arrows on the code line and in the bottom bar
Execute-Playground arrows on the code line and in the bottom bar

Note: Clicking the arrow next to a line of code runs the playground only up to that line.

If nothing appears in the sidebar, click the stop button, then click the run arrow again.

The sidebar displays values for some lines with buttons for Quick Look and Show Result.

➤ Click the Show Result button of the last code line to show the result below the code line. You can click the display window to resize it, if necessary.

Show Result of the code line.
Show Result of the code line.

"https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts%7CSilk%7CBedcovers&q=quilt"

Thanks to urlComponents, your query parameters are safely URL-encoded and appended to the base URL.

➤ In the sidebar, look at the url on the line above. Notice it’s not in quotation marks, because it’s not a String. In fact, it’s an Optional. Click its Quick Look button:

Playground trying to display a URL
Playground trying to display a URL

The playground tries its best to open the URL. Click anywhere to close the quick-look window.

You can create a URL from a String, if the String has all the right parts. Then, you can access these parts as properties of the URL instance: host, baseURL, path, lastPathComponent, query and so on.

If you try to create a URL from a String that wouldn’t work in a browser, the initializer returns nil. That’s why urlComponents.url is an Optional and there’s a url? in the last code line: If url is nil, it doesn’t have an absoluteString property.

➤ Click Hide Result to close the show-result window.

Note: You can also print(urlComponents.url?.absoluteString ?? "") to see the printed value in the Debug area below. If you’re not able to see the Debug area, click the button in the lower right corner or press Shift-Command-C.

Playground trying to display a URL
Playground trying to display a URL

URLComponents Helper Method

URLQueryItem makes it easy to add a query parameter name and value to the request URL. The name and value arguments of URLQueryItem look like dictionary key and value items, so it’s easy to create a dictionary of parameter names and values, then transform this dictionary into a queryItems array. It’s especially easy when Alfian Losari has already done it. :] It’s in Sources/URLComponentsExtension.swift in this playground.

➤ Replace the urlComponents.queryItems definition with:

var baseParams = [
  "hasImages": "true",
  "q": ""
]
urlComponents.setQueryItems(with: baseParams)

You create a dictionary whose keys are query parameter names. The first parameter hasImages matches any object whose web page displays at least one image. You include q (search term) with the default value "", and this dictionary lets you easily change its value.

The setQueryItems(with:) helper method defined in the URLComponents extension creates a URLQueryItem for each dictionary item and sets the queryItems array of the URLComponents instance.

➤ To set a query term, add this code below urlComponents.url?.absoluteString:

baseParams["q"] = "rhino"
urlComponents.setQueryItems(with: baseParams)
urlComponents.url?.absoluteString

You’re adding the query parameter q=rhino to the request URL by setting baseParams["q"] = "rhino" then calling setQueryItems(with:).

➤ Execute the playground and show the absoluteString results:

hasImages first, q second
hasImages first, q second

Or the parameters might be ordered the other way around:

q first, hasImages second
q first, hasImages second

Because baseParams is a dictionary, you can’t control the order of its elements.

➤ Copy and paste your absoluteString into your browser to see what you get.

If q=rhino is at the end of your absoluteString, you’ll get:

{"total":5,"objectIDs":[640907,452174,241715,452648,679031]}

The response body contains JSON data that maps directly into your ObjectIDs structure. Only eight objects match q=rhino, and only five of these have images.

If q=rhino appears before hasImages=true, you’ll get:

{"total":123,"objectIDs":[551786,472562,317877,544740,729644,329077,464273,53660,437422,459028,459027,544320,200668,451725,854970,435848,824771,436102,192770,438821,310453,437261,460281,453385,452102,207157,237451,817962,549236,438779,53162,838076,464294,435864,436885,436884,852562,748565,39901,437368,776714,435897,436098,204587,439327,197461,197460,437061,437173,844492,383883,485416,437202,334030,811771,811772,377933,687615,436099,452032,76034,437059,436838,437868,430812,850659,347927,736196,437216,626692,435621,759529,822751,448959,452740,40080,436658,888663,436803,437384,435702,435844,892108,36225,764636,436105,39742,437585,228995,436529,499559,437878,60470,464132,452364,200840,228990,53238,452651,201718,436607,437508,435991,712539,464118,435997,451287,73651,488221,44759,437873,341703,437159,453895,733847,448280,39895,437936,775454,450761,450605,435678,894011]}

This behavior is … unexpected. And not correct: None of these objects matches q=rhino. It seems the metmuseum.org API has an undocumented requirement that the q parameter must be the final parameter. So, for this API, you’ll need to add the q parameter manually.

➤ In the baseParams initialization, delete q="" and the comma at the end of "hasImages": "true":

var baseParams = [
  "hasImages": "true"
]

➤ Replace the last three lines with this code:

let queryTerm = "rhino wolf"
urlComponents.queryItems! += [URLQueryItem(name: "q", value: queryTerm)]
urlComponents.queryItems
urlComponents.url?.absoluteString

The queryItems property of urlComponents is an array of URLQueryItem name-value pairs, so you add the q parameter as a single-item array. Because it’s an array, the q parameter is always the final parameter, every time you run the playground. It’s actually an optional, but you know it isn’t nil because you already called urlComponents.setQueryItems(with:), so it’s safe to force-unwrap it.

➤ Execute the playground to check urlComponents.url?.absoluteString:

URL to search for `rhino wolf`
URL to search for `rhino wolf`

And URLComponents takes care of URL-encoding the space in "rhino wolf".

Now, you’re all set to send a request with this URL in the playground, then decode the data.

Sending the Request With URLSession

You’ve got your URL. Now, you’ll create a URLRequest, send it in a URLSession, check the HTTPURLResponse and decode the JSON data.

➤ Add this code:

let queryURL = urlComponents.url  // 1
let request = URLRequest(url: queryURL!)
let session = URLSession.shared  // 2
let decoder = JSONDecoder()  // 3

Task {  // 4
  let (data, response) = try await session.data(for: request)
  guard
    let response = response as? HTTPURLResponse,
    (200..<300).contains(response.statusCode)
  else {
    print(">>> response outside bounds")
    return
  }
  let objectIDs = try decoder.decode(ObjectIDs.self, from: data)  // 5
  objectIDs.objectIDs
}
  1. You create a URLRequest with queryURL. In this playground, you know queryURL is a valid URL, so it’s safe to force-unwrap it. When this code is in a method, you would do this assignment in a guard statement and exit if the value is nil.

  2. URLSession methods run in a session. The framework provides a shared session with default configuration that you can use for simple requests. If you need a custom configuration, you can create your own session. For example, here’s how you’d create a session that waits 300 seconds for a network connection:

let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 300
let session = URLSession(configuration: config)
  1. You create a JSONDecoder to decode the JSON data you’re about to download.

  2. data(for:) is asynchronous. To run it in a playground, you create an asynchronous Task to send the request, await the data and response, then check that the status code is in the success range.

  3. Finally, you decode the data into an ObjectIDs instance, then display the array of object IDs.

➤ Execute the playground to see the IDs of the two matching objects:

IDs of matching objects
IDs of matching objects

Next, you’ll send requests for each of these objects but first, a brief discussion about decoding JSON.

Decoding JSON

If there’s a good match between your data model and a JSON value, the default init() of JSONDecoder is poetry in motion, letting you decode complex structures of arrays and dictionaries in a single line of code. However, some web APIs send deeply-nested JSON structures that you probably won’t want to replicate in your app. Then, you’ll need to use CodingKey enumerations and custom init(from:) methods.

Decoding What You Need

In Chapter 19, “Saving Files”, you saw how easy it is to encode and decode the Team structure as JSON because all its properties are Codable. You were saving and loading your app’s own data model, so item names and structure in JSON format exactly matched your Team structure.

JSON values sent by real-world APIs rarely match the way you want to name or structure your app’s data. Very often, your app doesn’t need all the JSON values. You saw in the previous chapter the enormous number of fields in an object’s record:

Response body at codebeautify.org/jsonviewer
Response body at codebeautify.org/jsonviewer

The Object structure in your app contains only six of these:

struct Object: Codable, Hashable {
  let objectID: Int
  let title: String
  let creditLine: String
  let objectURL: String
  let isPublicDomain: Bool
  let primaryImageSmall: String
}

This will work perfectly fine: JSONDecoder will decode only these values from the response data and ignore the rest.

Note: If you need Swift structures and coding keys for the complete JSON schema, paste a sample response body into quicktype and select the Swift and Struct options.

Complete Object structure app.quicktype.io
Complete Object structure app.quicktype.io

Decoding When JSON Name != Property Name

JSON values sent by real-world APIs might not match the way you want to name or structure your app’s data.

Many APIs use snake_case for JSON names, but Swift property names use camelCase. There’s an easy fix; you simply tell the decoder to do the translation:

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

This takes care of translating a JSON name like artistWikidata_URL to a Swift property name like artistWikidataURL.

If the JSON structure matches your app’s data model, but some of the names are different, you simply have to define a CodingKey enumeration to assign JSON item names to your data model properties. For example, Object has a primaryImageSmall property that matches the JSON item’s name. The value of this key is a URL string, so you might want to name your app’s property imageURL. Here’s how you’d do it:

enum CodingKeys: String, CodingKey {
  case imageURL = "primaryImageSmall"
  case objectID, title, creditLine, objectURL, isPublicDomain
}

Unfortunately, as soon as you create a CodingKey enumeration for one property name, you must include all the property names, even those that already match JSON item names.

Decoding Nested JSON

Many APIs send JSON data whose structure is very different from the way you want to organize your app’s data. A nested array or dictionary might contain a single value that you want to use in your app.

Fortunately, this isn’t the case with the metmuseum.org data you need for TheMet, although the Object record does contain a few arrays. Suppose you want to use the Wikidata_URL of one of the term items in the tags array:

"tags": [
    {
        "term": "Animals",
        "AAT_URL": "http://vocab.getty.edu/page/aat/300249525",
        "Wikidata_URL": "https://www.wikidata.org/wiki/Q729"
    },
    {
        "term": "Poetry",
        "AAT_URL": "http://vocab.getty.edu/page/aat/300055931",
        "Wikidata_URL": "https://www.wikidata.org/wiki/Q482"
    },
    {
        "term": "Men",
        "AAT_URL": "http://vocab.getty.edu/page/aat/300025928",
        "Wikidata_URL": "https://www.wikidata.org/wiki/Q8441"
    },
    {
        "term": "Horses",
        "AAT_URL": "http://vocab.getty.edu/page/aat/300250148",
        "Wikidata_URL": "https://www.wikidata.org/wiki/Q726"
    }
]

There are two approaches to decoding nested JSON:

  1. Define your data model to mirror the JSON value and see how nifty automatic JSON decoding can be.
  2. Flatten the JSON value into your data model: In exchange for more decoding work, you’ll get sensible data structures that are easier and more natural to work with.

To see how to do this and much more, check out our tutorial Encoding and Decoding in Swift.

Downloading Objects

Now, back to your playground code, where you sent a query request then decoded the response data into an ObjectIDs instance. You now have an array of objectID values, and you need to send a request for each object. You’ll store the downloaded objects in an array.

➤ Above the Task closure, add this line:

var objects: [Object] = []

You create an empty array of Object items.

➤ In the Task closure, add this code:

for objectID in objectIDs.objectIDs {
  let objectURLString = baseURLString + "objects/\(objectID)"  // 1
  let objectURL = URL(string: objectURLString)
  let objectRequest = URLRequest(url: objectURL!)
  let (data, response) = try await session.data(for: objectRequest)  // 2
  guard
    let response = response as? HTTPURLResponse,
    (200..<300).contains(response.statusCode)
  else {
    print(">>> response outside bounds")
    return
  }
  let object = try decoder.decode(Object.self, from: data)  // 3
  objects.append(object)
}
objects

You loop over the values in objectIDs.objectIDs. For each objectID, you run code that’s very similar to what you did for the query request:

  1. You construct the endpoint URL for this objectID and use it to create a URLRequest.
  2. You send the request, await the data and response, then check the status code.
  3. You decode data into an Object instance, then append it to your objects array. To check the array, you display it.

➤ Execute the playground, show the result for objects and open an object:

Array of matching objects
Array of matching objects

Your JSON decoding is all working, and this is as much as you can test in a playground. You’re ready to copy and adapt all this code into your app to make everything work!

Downloading Data in Your App

Your playground code is working fine, sending requests for objects that match a query term and decoding the JSON responses. Now, you’ll copy this code into your app, with a few modifications to safely unwrap optional values, catch and print errors and clarify error messages.

➤ Open the project in this chapter’s starter folder: It’s the same as the final project from Chapter 22, plus URLComponentsExtension.swift. If you prefer to continue with your project, open the playground’s navigation panel and drag this file into your project from the playground’s Sources folder.

➤ In Project navigator, in the Preview Content group, delete MetStoreDevData.swift. You’ll initialize the objects array from URLSession response data.

➤ In TheMetStore.swift, replace init() with this code:

func fetchObjects(for queryTerm: String) async throws {
}

Next, you’ll use your playground code to implement some helper methods that fetchObjects(for:) will call. The helper methods will be asynchronous and might throw errors, so fetchObjects(for:) also has these keywords.

TheMetService

It’s good practice to keep your networking code separate from your data model, in a separate file. And, it’s common to use either “networking” or “service” in the filename.

➤ Create a new Swift file and name it TheMetService.swift. Below import Foundation, add this structure:

struct TheMetService {
  let baseURLString = "https://collectionapi.metmuseum.org/public/collection/v1/"
  let session = URLSession.shared
  let decoder = JSONDecoder()

  func getObjectIDs(from queryTerm: String) async throws -> ObjectIDs? {
    // insert code here
    return nil
  }

  func getObject(from objectID: Int) async throws -> Object? {
    return nil
  }
}

The first three lines are from your playground, and you’ll adapt playground code to implement the two methods. fetchObjects(queryTerm:) in TheMetStore will first call getObjectIDs(from:), then loop over the objectIDs array, calling getObject(from:) for each objectID.

Both methods are async because they’ll call session.data(for:), and both methods can rethrow errors thrown by session.data(for:). The JSONDecoder can also throw errors, but these errors are extremely useful for finding any JSON-decoding problems, so you’ll catch and print them right away. For now, both methods return nil, so the compiler doesn’t complain.

getObjectIDs(from:)

➤ In getObjectIDs(from:), insert this code above return nil:

let objectIDs: ObjectIDs?  // 1

guard
  var urlComponents = URLComponents(string: baseURLString + "search")
else {  // 2
  return nil
}
let baseParams = ["hasImages": "true"]
urlComponents.setQueryItems(with: baseParams)
urlComponents.queryItems! += [URLQueryItem(name: "q", value: queryTerm)]
guard let queryURL = urlComponents.url else { return nil }
let request = URLRequest(url: queryURL)
  1. You’ll decode data into objectIDs, then return this structure.
  2. You create the URLRequest, taking greater care to unwrap most of the optional values.

➤ Now, replace the final return nil with this code:

let (data, response) = try await session.data(for: request)  // 1
guard
  let response = response as? HTTPURLResponse,
  (200..<300).contains(response.statusCode)
else {
  print(">>> getObjectIDs response outside bounds")
  return nil
}

do {  // 2
  objectIDs = try decoder.decode(ObjectIDs.self, from: data)
} catch {
  print(error)
  return nil
}
return objectIDs  // 3
  1. This is the playground code that calls data(for:), awaits data and response, then checks the status code. You add getObjectIDs to the print message, so you know which method had the problem. Because getObjectIDs is an asynchronous method, it already runs in an asynchronous context, so you don’t need to embed this code in a Task.
  2. The decoder can throw errors, so you call it in a do-catch statement. You print the raw error value, as this gives you more information about what went wrong.
  3. If execution reaches this line, everything has worked without errors, and you return ObjectIDs.

getObject(from:)

fetchObjects(queryTerm:) in TheMetStore will loop over the objectIDs array, calling getObject(from:) for each objectID.

➤ In getObject(from:), replace return nil with this code:

let object: Object?  // 1

let objectURLString = baseURLString + "objects/\(objectID)"  // 2
guard let objectURL = URL(string: objectURLString) else { return nil }
let objectRequest = URLRequest(url: objectURL)

let (data, response) = try await session.data(for: objectRequest)  // 3
if let response = response as? HTTPURLResponse {
  let statusCode = response.statusCode
  if !(200..<300).contains(statusCode) {
    print(">>> getObject response \(statusCode) outside bounds")
    print(">>> \(objectURLString)")
    return nil
  }
}

do {  // 4
  object = try decoder.decode(Object.self, from: data)
} catch {
  print(error)
  return nil
}
return object  // 5
  1. You’ll decode data into object, then return this structure.

  2. You create the URLRequest, taking greater care to unwrap the optional value. You don’t need to use URLComponent to construct objectURLString because objectID is an Int, so there won’t be any characters that need to be URL-encoded.

  3. This is modified from the playground code that calls data(for:), awaits data and response, then checks the status code. Some objectID values return 404-not-found, so you print the actual statusCode and the problem URL string.

  4. The decoder can throw errors, so you call it in a do-catch statement.

  5. If execution reaches this line, everything has worked without errors, and you return the resulting Object.

Now, head back to TheMetStore to use these two methods.

Fetching Objects in ContentView

TheMetStore is closely connected to your SwiftUI views — its main responsibility is to publish values for the views to present to users. It uses TheMetService to do this.

fetchObjects(for:)

➤ In TheMetStore.swift, add these properties to TheMetStore:

let service = TheMetService()
let maxIndex: Int

You create an instance of TheMetService so you can call its methods. getObjectIDs(from:) could return a very large array, so you’ll use maxIndex to cap the number of calls to getObject(from:). This is a courtesy to The Metropolitan Museum, which asks “Please limit request rate to 80 requests per second.” You’ll also use this value in the next chapter to keep your widget testing manageable.

➤ Next, add this initializer:

init(_ maxIndex: Int = 30) {
  self.maxIndex = maxIndex
}

You set 30 as the default maxIndex value. You don’t need to change the @StateObject line in ContentView unless you want a different maxIndex value.

➤ Now, add this code to fetchObjects(for:):

if let objectIDs = try await service.getObjectIDs(from: queryTerm) {  // 1
  for (index, objectID) in objectIDs.objectIDs.enumerated()  // 2
  where index < maxIndex {
    if let object = try await service.getObject(from: objectID) {
      objects.append(object)
    }
  }
}
  1. First, you call getObjectIDs(from:) and wait for it to return objectIDs.
  2. Then, you loop over objectIDs.objectIDs — at most maxIndex of them — calling getObject(from:) for each objectID. If it returns an Object, you append it to your objects array.

You’re nearly there! Head back to ContentView.swift to call fetchObjects(for:).

Sending the First Request

➤ In the body of ContentView, fold the VStack so you can see the closing brace of NavigationStack, then add this modifier to NavigationStack:

.task {
  do {
    try await store.fetchObjects(for: query)
  } catch {}
}

When the app starts, ContentView appears and this task runs. Because it modifies NavigationStack, it runs only once, no matter how often you navigate to ObjectView and back to ContentView.

➤ Refresh Live Preview:

Search for rhino when the app starts.
Search for rhino when the app starts.

The app lists the three public-domain objects that match “rhino”. Tap Terracotta oil lamp to see a rhino tossing a lion with its horn — that explains why this object matches “rhino”.

Next, you need to call fetchObjects(for:) when the user enters a new query term.

Sending a New Request

Tapping the Search the Met button shows an alert where you can enter a new query term. Tapping Search or the return key should call fetchObjects(for:).

➤ Unfold the VStack and locate the alert modifier. Add this code inside the button’s action closure:

Task {
  do {
    store.objects = []
    try await store.fetchObjects(for: query)
  } catch {}
}

SwiftUI views run synchronously, so you embed the call to fetchObjects(for:) in a Task.

➤ Build and run the project in a simulator, then tap the search button and enter a term like “persimmon”:

Search for persimmon.
Search for persimmon.

You get a much longer list, including two non-public-domain objects that have images.

What if you decide to search for a different term while the app is still downloading objects for the current query term?

➤ Search for something that fetches a lot of objects, like “cat”. While these are still downloading, search for “rhino” — you know this query returns only three objects.

Search for cat then search for rhino.
Search for cat then search for rhino.

Although the alert button’s action resets objects to the empty array, the “cat” task continues to run, so cat objects swamp the three rhino objects.

➤ Stop the project in Xcode to stop the cat downloads.

Canceling the Running Task

You need to cancel the running task before starting the next task. To cancel a task, you must give it a name.

➤ Add this property to ContentView:

@State private var fetchObjectsTask: Task<Void, Error>?

You’ll store the alert button task in fetchObjectsTask and cancel it before you start the next task.

➤ In the alert button’s action code, replace Task { with:

fetchObjectsTask?.cancel()
fetchObjectsTask = Task {

If there’s a fetchObjectsTask, you cancel it. In any case, save the new Task to fetchObjectsTask.

➤ Build and run the project in a simulator, search for “cat”, then search for “rhino”:

Cancel search for cat before searching for rhino.
Cancel search for cat before searching for rhino.

That worked! But, what are these purple errors?

Publishing changes from background threads is not allowed...
Publishing changes from background threads is not allowed...

Publishing Changes on the Main Thread

“Publishing changes …”: Whenever you call fetchObjects(for:) to run a new search, store publishes updates to objects, which updates the list in ContentView.

➤ Open TheMetStore.swift:

Purple published property
Purple published property

The purple points to the line of code that appends object to the published property objects. ContentView subscribes to this property by creating an instance of TheMetStore. The asynchronous method fetchObjects(for:) changes objects, which changes ContentView.

“… from background threads”: What’s this about threads? Your app runs its code on threads — the main thread and one or more background threads. The user interface of every iOS app runs on the main thread. A SwiftUI app’s user interface consists of SwiftUI views, so these always run on the main thread. The main thread must always be responsive to the user, so asynchronous methods run on a background thread to avoid slowing down or blocking the main thread.

Here’s the problem: Any code that updates the user interface must run on the main thread. If an asynchronous method contains code that updates the user interface, it must somehow run that code on the main thread. By default, Xcode runs a Main Thread Checker, which you can see in the Edit Scheme… ➤ Diagnostics tab:

Main Thread Checker
Main Thread Checker

To run UI-update code on the main thread from an asynchronous function, the “old concurrency” way was to dispatch this code to the main queue. The new Swift concurrency way to do this is to run the code on MainActor.

➤ In fetchObjects(for:), replace objects.append(object) with this code:

await MainActor.run {
  objects.append(object)
}

This is the only line of code that must run on the main thread, so you embed it in a MainActor.run closure. You use await to call it asynchronously, so the system can suspend and resume execution on the correct actor.

Note: You can annotate a method with @MainActor to ensure it runs on the main thread or annotate a property to ensure it can only be updated from the main thread. Or you can annotate an entire class with @MainActor, if almost all its properties and methods need to be on the main thread, then mark any exceptions with the nonisolated keyword. Learn more about Swift concurrency from our book Modern Concurrency in Swift or video courses Modern Concurrency: Getting Started and Modern Concurrency: Beyond the Basics.

➤ Build and run the project in a simulator, then enter a search term:

Purple problem solved.
Purple problem solved.

Purple problem solved! Your app is working perfectly, but there’s one last thing…

Showing a Progress View

After entering a new query term, there’s a brief moment when the list is blank. Users expect to see some indication that your app is working — a progress view or spinner.

➤ In ContentView.swift, add this modifier to VStack:

.overlay {
  if store.objects.isEmpty { ProgressView() }
}

➤ Refresh Live Preview, then enter a new search term:

Progress view while objects is empty
Progress view while objects is empty

A progress view spins until the first object appears.

Congratulations, you’ve built a working app for exploring the collections of The Metropolitan Museum of Art, New York. And, you’re now ready to apply everything you’ve learned about URLSession, URLComponents and JSONDecoder to your own apps.

Key Points

  • The URLSession API involves many different classes that work together in a fairly complex way. For simple download tasks, use the built-in shared session. Use URLComponents to create a URL-encoded URL from the endpoint string and query parameters, then create a URLRequest from this URL. Send this request with data(for:) and await the Data instance and URLResponse object. If the status code indicates success, use a JSONDecoder to decode the data into your data model.

  • Create a separate structure to define your networking methods, then instantiate this in your data model.

  • Asynchronous methods run on background threads. Any code that updates the user interface must run on the main thread. One way to do this is to call MainActor.run.

  • Playgrounds are useful for working out code. You can quickly inspect values produced by methods and operations.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.