iosswiftuiasync-awaitswiftui-zstack

How to construct a SwiftUI view using async


I've got a little swiftUI program that displays photos from a given day as stacks of photos for each year. Simplified code below. I read the photos into resultPhotos as a dictionary with each key the year, and each value a PHFetchResult. I then loop through each year in the dictionary and create myImageStack with the photos for that year.

Tested with around 100 photos, this is very slow. The culprit appears to be building the stack of images in myImageStack.

I'd like to try to build myImageStack asynchronously so as to not freeze the UI. Any guidance on how to do that? Most of the async/await examples I've found are built around returning data, but I'm trying to return a view that will be used in a ForEach loop. I feel like I'm missing something fundamental here.

(I realize that many of the images are completely covered in myImageStack, so perhaps I should be simplifying that view as well. But as the images are stacked randomly, you can see pieces of some of the images in the stack and I'd like to maintain that.)

import SwiftUI
import Foundation
import Photos

struct PhotosOverviewView_simplified: View {
    
    var body: some View {
        let photos = PhotosModel()
        let _ = photos.getPhotoAuthorizationStatus()
        let resultPhotos = photos.getAllPhotosOnDay(monthAbbr:"Feb", day:16)
        
        NavigationStack{
            ScrollView(.vertical){
                VStack(alignment: .center){
                    
                    //For each year, create a stack of photos
                    ForEach(Array(resultPhotos.keys).sorted(), id: \.self) { key in
                        let stack = myImageStack(resultsPhotos: resultPhotos[key]!)
                        
                        NavigationLink(destination: PhotosYearView(year: key, resultsPhotos: resultPhotos[key]!)){
                            stack
                                .padding()
                        }
                    }
                }
                .frame(maxWidth: .infinity)
            }
        }
    }
}


struct myImageStack: View {
    let resultsPhotos: PHFetchResult<PHAsset>
    
    var body: some View {
        let collection = PHFetchResultCollection(fetchResult: resultsPhotos)
        
        ZStack{
            ForEach(collection, id: \.localIdentifier){ p in
                    Image(uiImage: p.getAssetThumbnail())
                        .resizable()
                        .scaledToFit()
                        .border(Color.white, width:10)
                        .shadow(color:Color.black.opacity(0.2), radius:5, x:5, y:5)
                        .frame(width: 200, height: 200)
                        .rotationEffect(.degrees(Double.random(in:-25...25)))
            }
        }
    }
}



Solution

  • Most of the async/await examples I've found are built around returning data, but I'm trying to return a view that will be used in a ForEach loop. I feel like I'm missing something fundamental here.

    Yep, this is exactly your problem. The point of SwiftUI is that it is declarative. You cannot do async work to generate Views because Views describe the layout. They are not like UIKit views that represent the actual thing on the screen.

    You're having trouble because you haven't considered what to display while the data is loading. Your View needs to consider all of its possible states. Those states, unsurprisingly, go in @State properties. When the state changes, the View will be updated. Another pattern is the @StateObject pattern, such that when a reference type updates a @Published property, the View will be updated. But the key lesson is: The View always displays its current state. When the state changes, the View is re-rendered.

    So for your example, you might expect something like:

    struct Photo: Identifiable {
        let id: String
    }
    
    @MainActor
    class PhotosModel: ObservableObject {
        @Published var photos: [Photo] = []
    
        init() {}
    
        func load(month: String, day: Int) async throws {
            // Do things to update `photos`
        }
    }
    
    struct PhotosOverviewView_simplified: View {
        // Whenever a @Published property of model updates, body will re-render
        // But the *value* of body does not actually change. It always describes
        // the View in all possible states.
        @StateObject var model = PhotosModel()
    
        var body: some View {
            NavigationStack{
                ScrollView(.vertical){
                    VStack(alignment: .center){
    
                        // You should not think of this ForEach as a loop that 
                        // executes. It just describes the View. It says "there
                        // is a YourCustomPhotoView for each element of photos."
                        // It does not actually make those Views. The renderer
                        // will do that at some future time.
                        ForEach(model.photos) { photo in
                            YourCustomPhotoView(photo)
                        }
                    }
                }
            }
            .task { // When this is displayed, start a loading task
                do {
                    try await model.load(month:"Feb", day: 16)
                } catch {
                    // Error handling
                }
            }
        }
    }
    

    I've waved my hands a little here with Error handling to keep this simple. Typically what you'd need to do is have a @State variable to indicate if you're in an error state. Inside body, you'd then have something like:

    if let error {
        CustomErrorView(error)
    else {
        ... the rest of your current body ...
    }
    

    Again, the point is that body describes all possible states. It does not mutate into an ErrorView or anything like that. The if here just describes the different states. It should not be thought of as code that executes at runtime.

    (And don't forget to check out AsyncImage. It may do a lot of what you want automatically.)