Using AsyncImages & finishing our app

Let’s add a State property to our PhotoGrid containing the image URLs as a String array.

@State var imageURLs = [String]()

Of course, our PhotoGrid also needs to know the coordinates of the location from which the images should be downloaded.

struct PhotoGrid: View {
    
    var latitudeToSearchFor: Double
    var longitudeToSearchFor: Double
    
    @State var imageURLs = [String]()
    
    var body: some View {
        //...
    }
}

Accordingly, we have to update our PhotoGrid_Previews struct …

struct PhotoGrid_Previews: PreviewProvider {
    static var previews: some View {
        PhotoGrid(latitudeToSearchFor: 48.864716, longitudeToSearchFor: 2.349014)
    }
}

… and the .sheet modifier in our ContentView:

.sheet(isPresented: $showPhotoGrid, content: {
    PhotoGrid(latitudeToSearchFor: (currentAnnotation?.coordinate.latitude)!, longitudeToSearchFor: (currentAnnotation?.coordinate.longitude)!)
})

Note: Exceptionally we can force-unwrap here because we can be sure that an annotation exists (without it, we couldn’t even open the PhotoGrid!)

Back to our PhotoGrid: We want to start downloading and parsing the JSON data when the PhotoGrid appears. To do this, we apply the .onAppear modifier, call the fetchImageURLs function and assign the returned array to our imageURLs State.

var body: some View {
    GeometryReader { geometry in
        //...
    }
        .onAppear {
            imageURLs = fetchImageURLs(fromFlickrURL: generateFlickrURL(latitude: latitudeToSearchFor, longitude: longitudeToSearchFor, numberOfPhotos: numberOfPhotos))
        }
}

However, Xcode prompts an error message again, this time reading: “‘async’ call in a function that does not support concurrency”. This is because we try to embed an asynchronous function inside a SwiftUI view that works synchronously. However, as we learned in the last section, asynchronous code only works within an asynchronous context. For this purpose, SwiftUI provides a way to embed asynchronous code by using the “Task” wrapper.

.onAppear {
    Task {
        imageURLs = fetchImageURLs(fromFlickrURL: generateFlickrURL(latitude: latitudeToSearchFor, longitude: longitudeToSearchFor, numberOfPhotos: numberOfPhotos))
    }
}

Again, we need to use the “try await” keywords to tell Swift, that our function can throw errors and the fetchImageURLs works asynchronously.

.onAppear {
    Task {
        imageURLs = try await fetchImageURLs(fromFlickrURL: generateFlickrURL(latitude: latitudeToSearchFor, longitude: longitudeToSearchFor, numberOfPhotos: numberOfPhotos))
    }
}

Load and display images asynchronously using AsyncImage views 🖼🔁

Next, we want to use the imageURLs array to load the individual images in the PhotoGrid. This process should also be done asynchronously to save system resources and not block the interface until all 400 images have been loaded. Since iOS 15 AsyncImage views are available for this purpose. An AsyncImage only needs the corresponding URL and then handles the download and rendering process for us.

So let’s adjust our ForEach loop to create an AsyncImage for each string in the imageURLs.

ForEach(imageURLs, id: \.self) { fetchedImageURL in
    AsyncImage(url: URL(string: fetchedImageURL)) { image in
        image
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(width: geometry.size.width/2, height: geometry.size.width/2)
            .clipped()
    }
}

Each AsyncImage still needs a placeholder, which is displayed as long as the respective image has not been downloaded yet.

AsyncImage(url: URL(string: fetchedImageURL)) { image in
    image
        .resizable()
        .aspectRatio(contentMode: .fill)
        .frame(width: geometry.size.width/2, height: geometry.size.width/2)
        .clipped()
} placeholder: {

}

As the placeholder, we want to use a simple activity indicator. For this, we initiate a ProgressView and use the CircularProgressViewStyle.

AsyncImage(url: URL(string: fetchedImageURL)) { image in
    image
        .resizable()
        .aspectRatio(contentMode: .fill)
        .frame(width: geometry.size.width/2, height: geometry.size.width/2)
        .clipped()
} placeholder: {
    ProgressView()
        .progressViewStyle(CircularProgressViewStyle())
        .frame(width: geometry.size.width/2, height: geometry.size.width/2)
}

Okay, let’s see if this works. Run the app, drop a pin, and tap on it. The PhotoGrid opens and after a few moments, we can watch our AsyncImage views fetching the images from Flickr!

Using the fetchImageURLs function, we receive a JSON, which we parse. By using the extracted values, we generate the URLs for each image to be downloaded. These URLs are then used to download the corresponding images asynchronously using AsyncImage views.

The cool thing about the combination of our LazyVGrid and AsyncImage views is that it doesn’t load all 400 images at once, but only loads as many as are needed to display them seamlessly on the screen. You can notice this by scrolling down fast and watching the newly rendered AsyncImage views loading.

Handling different search phases

The search for images in the Flickr database can go through different phases. Right now we have covered only one of these phases, namely that images have been successfully found in the database and a corresponding JSON is available.

However, there is also the phase when the search is still in progress. In the meantime, the user is still staring at an empty screen. Finally, there may be a situation where no images were found at all.

To represent these three possibilities we add a corresponding enum to our PhotoGrid.

enum SearchStatus {
    case noImagesFound
    case imagesFound
    case searching
}

Finally, we keep track of the phase using an appropriate State property. By default, we assume that we are still in the search and fetch process.

@State var searchStatus: SearchStatus = .searching

Next, we modify our Task in the .onAppear modifier to change the searchStatus accordingly:

.onAppear(perform: {
    Task {
        imageURLs = try await fetchImageURLs(fromFlickrURL: generateFlickrURL(latitude: latitudeToSearchFor, longitude: longitudeToSearchFor, numberOfPhotos: numberOfPhotos))
        if imageURLs.isEmpty {
            print("No Image URLs found")
            searchStatus = .noImagesFound
        } else {
            searchStatus = .imagesFound
        }
    }
})

Depending on whether we are currently searching for images in the database, have successfully found some in the database, or none are available, we want to adjust the content of the PhotoGrid.

Accordingly, we use a switch statement embedded into a VStack inside the GeometryReader of our PhotoGrid and display the ScrollView with its contents only when the .imagesFound case is given.

 GeometryReader { geometry in
     VStack {
         switch searchStatus {
             case .noImagesFound:
                    
             case .imagesFound:
                ScrollView {
                    LazyVGrid(columns: [GridItem(spacing: 0), GridItem(spacing: 0)], spacing: 0) {
                        ForEach(imageURLs, id: \.self) { fetchedImageURL in
                            AsyncImage(url: URL(string: fetchedImageURL)) { image in
                                image
                                    .resizable()
                                    .aspectRatio(contentMode: .fill)
                                    .frame(width: geometry.size.width/2, height: geometry.size.width/2)
                                    .clipped()
                            } placeholder: {
                                ProgressView()
                                    .progressViewStyle(CircularProgressViewStyle())
                                    .frame(width: geometry.size.width/2, height: geometry.size.width/2)
                            }
                        }
                    }
                }
            case .searching:

            }
        }
    }

To make sure everything inside the VStack is positioned and sized correctly, let’s apply a frame to the VStack covering the whole GeometryReader view:

GeometryReader { geometry in
    VStack {
        //...
    }
        .frame(width: geometry.size.width, height: geometry.size.height, alignment: .center)
}

While the search process is still running, on the other hand, we want to display a simple spinning activity indicator.

case .searching:
    ProgressView()
        .progressViewStyle(CircularProgressViewStyle())

If no images were found at the given location, we want to display a corresponding hint to the user.

case .noImagesFound:
    Image(systemName: "questionmark")
        .resizable()
        .aspectRatio(contentMode: .fit)
        .frame(width: geometry.size.width/12, height: geometry.size.width/8)
    Text("No photos found at this location ):")
        .padding()

Let’s see if this works. When we run the app and search for images in, say, a densely populated city, we first see an activity indicator. Only after the JSON has been fetched and parsed, the searchStatus changes and we see the ScrollView with the LazyVGrid accordingly.

If, on the other hand, we set a needle in the middle of the Atlantic Ocean, we will probably not find any images and we will get a corresponding indication.

Conclusion 🎊

Great! We just finished creating our City Explorer App. And damn, we learned a lot of stuff!

We dived into working with maps in SwiftUI, learned how to access the user’s location, and how to let the user drop a pin annotation at a specific location. We then used the geographical coordinates of this location to search for photos that were shot by Flickr users in a certain radius around this place.

We used the Flickr API to download these photos and present them inside a Grid view. While the photos are being downloaded, we are presenting a progress bar indicating how many photos have already been fetched!

Leave a Reply

Your email address will not be published. Required fields are marked *