Concurrency in modern Swift with Async & Await in SwiftUI — Part 4

DevTechie Inc
Jul 20, 2023

Concurrency in modern Swift with Async & Await in SwiftUI — Part 4

Let’s continue our exploration with AsyncImage and refreshable in this article.

AsyncImage

Downloading images is a common task, so the SwiftUI team decided to make things even more convenient for app developers by introducing the AsyncImage view with the release of iOS 15.

This view uses the shared instance of URLSession, similar to what we have been doing using async and await, to load an image from the specified URL.

The syntax for AsyncImage is very simple.

AsyncImage(url: URL(string: "..."))

Let’s change our code to use AsyncImage. We will continue to ask for 4000x4000 image from API and you will see the reason.

struct DevTechieAsyncAwaitExample: View {
    
    var body: some View {
        NavigationStack {
            VStack {
               AsyncImage(url: URL(string: "https://picsum.photos/4000"))
                    .frame(width: 400, height: 400)
            }
            .navigationTitle("DevTechie")
        }
    }
}

Build and run

Notice the gray box right before the image is loaded; it’s the placeholder view. AsyncImage provides a placeholder view while the system gets the image from the API. Placeholder view is replaced with the actual image once its received.

Another thing to notice in this example is the size of the image. Since we are requesting a 4000x4000 pixel image, we are getting one, but despite specifying the frame for the view, our image takes the entire screen.

The reason is that AsyncImage returns the image in its full resolution and we can’t apply manipulating modifiers on the image as they are defined at the view level.

To give more control over how and what needs to be shown inside the view, AsyncImage has another overload which gives us access to the image in trailing closure. At the same time, it also gives us the ability to define our own placeholder view.

AsyncImage(url: URL(string: "...")) { image in
    image.resizable()
} placeholder: {
    // a view for placeholder
}

Let’s use this overload and update our code. We will use SwiftUI’s ProgressView for the placeholder view.

struct DevTechieAsyncAwaitExample: View {
    
    var body: some View {
        NavigationStack {
            VStack {
                AsyncImage(url: URL(string: "https://picsum.photos/4000")) { image in
                    image.resizable()
                        .frame(width: 400, height: 400)
                } placeholder: {
                    ProgressView()
                }
                .frame(width: 400, height: 400)
            }
            .navigationTitle("DevTechie")
        }
    }
}

Build and run


Async Data Refresh

Let’s continue our exploration of async await with another example. This time, we will build an app to fetch a random list of coffees. We will display them in a List view and add pull-to-refresh functionality to refresh the list with new data.

We will be using random-data-api.com to fetch dummy data, but if you have a service or API of your choice, please feel free to use that.

API url:

https://random-data-api.com/api/coffee/random_coffee?size=10

Opening the link will give us a JSON response, for better readability. Let’s change the size to be 1.

[
   {
      "id":1272,
      "uid":"c36bd635-ec6a-43c2-8a54-b57a2c86397d",
      "blend_name":"Good-morning Symphony",
      "origin":"Managua, Nicaragua",
      "variety":"Java",
      "notes":"structured, tea-like, white pepper, tomato, lemonade",
      "intensifier":"juicy"
   }
]

Next, we will construct our data structure based on this JSON response.

struct Coffee: Codable, Identifiable {
    let id: Int
    let uid, blendName, origin, variety: String
    let notes, intensifier: String
enum CodingKeys: String, CodingKey {
        case id, uid
        case blendName = "blend_name"
        case origin, variety, notes, intensifier
    }
}

Let’s add the WebService class and call the API.

class WebService {
    func getCoffeeList() async throws -> [Coffee] {
        let (data, _) = try await URLSession
            .shared
            .data(from: URL(string: "https://random-data-api.com/api/coffee/random_coffee?size=10")!)
        return try JSONDecoder().decode([Coffee].self, from: data)
    }
}

We will use a simple List view to render the response on UI.

struct DevTechieAsyncAwaitExample: View {
    @State private var coffees = [Coffee]()
    
    var body: some View {
        NavigationStack {
            VStack {
                List(coffees) { coffee in
                    VStack(alignment: .leading, spacing: 5) {
                        Text(coffee.blendName)
                            .font(.title3)
                        Text("Notes: \(coffee.notes)")
                            .font(.subheadline)
                            .foregroundStyle(.secondary)
                        HStack {
                            Text("Origin: \(coffee.origin)")
                            Spacer()
                            Text("Variety: \(coffee.variety)")
                        }
                        .font(.caption)
                        .foregroundStyle(.tertiary)
                    }
                }
            }
            .task {
                do {
                    coffees = try await WebService().getCoffeeList()
                } catch {
                    print(error.localizedDescription)
                }
            }
            .navigationTitle("DevTechie")
        }
    }
}

Build and run.

Notice that the server takes a bit to respond back, but our UI stays responsive.

We will add an empty state for the list.

struct DevTechieAsyncAwaitExample: View {
    @State private var coffees = [Coffee]()
    
    var body: some View {
        NavigationStack {
            VStack {
                if coffees.isEmpty {
                    ZStack {
                        Image(systemName: "heater.vertical.fill")
                            .font(.system(size: 60))
                            .rotationEffect(.degrees(-90))
                            .offset(y: -20)
                            .foregroundStyle(.gray.opacity(0.4).shadow(.inner(radius: 2)))
                        Image(systemName: "cup.and.saucer.fill")
                            .font(.system(size: 100))
                            .foregroundStyle(.gray.shadow(.inner(radius: 2)))
                    }
                    
                } else {
                    List(coffees) { coffee in
                        VStack(alignment: .leading, spacing: 5) {
                            Text(coffee.blendName)
                                .font(.title3)
                            Text("Notes: \(coffee.notes)")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                            HStack {
                                Text("Origin: \(coffee.origin)")
                                Spacer()
                                Text("Variety: \(coffee.variety)")
                            }
                            .font(.caption)
                            .foregroundStyle(.tertiary)
                        }
                    }
                }
            }
            .task {
                do {
                    coffees = try await WebService().getCoffeeList()
                } catch {
                    print(error.localizedDescription)
                }
            }
            .navigationTitle("DevTechie")
        }
    }
}

Adding pull-to-refresh is as easy as adding another modifier in SwiftUI. With the introduction of refreshable (action:), pull-to-refresh is piece of cake. We apply this modifier to a view to set the refresh value in the view’s environment to a RefreshAction instance that uses the specified action as its handler.

Refreshable modifier, just like task modifier can call async functions.

We will attach this modifier to our List view. Before we attach the refreshable modifier, let’s move our code, which calls the API, into a private function so we can reuse it.

Since we are awaiting on response, we will mark this new function as async as well.

private func refreshData() async {
    do {
        coffees = try await WebService().getCoffeeList()
    } catch {
        print(error.localizedDescription)
    }
}

Time to add pull-to-refresh

struct DevTechieAsyncAwaitExample: View {
    @State private var coffees = [Coffee]()
    
    var body: some View {
        NavigationStack {
            VStack {
                if coffees.isEmpty {
                    ZStack {
                        Image(systemName: "heater.vertical.fill")
                            .font(.system(size: 60))
                            .rotationEffect(.degrees(-90))
                            .offset(y: -20)
                            .foregroundStyle(.gray.opacity(0.4).shadow(.inner(radius: 2)))
                        Image(systemName: "cup.and.saucer.fill")
                            .font(.system(size: 100))
                            .foregroundStyle(.gray.shadow(.inner(radius: 2)))
                    }
                    
                } else {
                    List(coffees) { coffee in
                        VStack(alignment: .leading, spacing: 5) {
                            Text(coffee.blendName)
                                .font(.title3)
                            Text("Notes: \(coffee.notes)")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                            HStack {
                                Text("Origin: \(coffee.origin)")
                                Spacer()
                                Text("Variety: \(coffee.variety)")
                            }
                            .font(.caption)
                            .foregroundStyle(.tertiary)
                        }
                    }
                    .refreshable {
                        await refreshData()
                    }
                }
            }
            .task {
               await refreshData()
            }
            .navigationTitle("DevTechie")
        }
    }
    
    private func refreshData() async {
        do {
            coffees = try await WebService().getCoffeeList()
        } catch {
            print(error.localizedDescription)
        }
    }
}

Build and run to see this in action.