swiftuiswiftui-list

SwiftUI List with images from API is lagging


I'm using SwiftUI to build a list with images from an API. There's some lagging while the image is being downloaded. I can understand that the issue comes from the CatRowView, because when I replace it with a Image(systemName: it doesn't lag, but AsyncImage is async, so I don't know why this happens.

final class CatsGridViewModel: ObservableObject {
    private let apiService: APIService
    private (set) var page = 0
    @Published var catsArray: [Cat] = []

    var isLoading = false
    
    init(apiService: APIService = APIService()) {
        self.apiService = apiService
    }

    func isLastCat(id: String) -> Bool {
        return catsArray.last?.id == id
    }
    
    @MainActor
    func fetchCats() async {
        if !isLoading {
            isLoading = true
            let array = await apiService.fetchImages(.thumb, page: page, limit: 25)
            catsArray.append(contentsOf: array)
            page += 1
            isLoading = false
        }
    }
}

struct CatGridView: View {
    
    @StateObject var viewModel = CatsGridViewModel()
    
    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVStack {
                    ForEach(viewModel.catsArray) { cat in
                        VStack {
                            CatRowView(cat: cat)
                                .padding(.horizontal)
                            }.onAppear {
                                if viewModel.isLastCat(id: cat.id) {
                                    Task {
                                        await viewModel.fetchCats()
                                    }
                                }
                            }
                        }
                }
            }
            .task {
                await viewModel.fetchCats()
            }
            .navigationTitle("Cats: \(viewModel.page)")
        }
    }
}

struct CatRowView: View {
    
    var cat: Cat
    
    var body: some View {
        HStack {
            AsyncImage(url: URL(string: cat.url)!) { image in
                image
                    .resizable()
                    .clipShape(Circle())
                    .frame(width: 100, height: 100)
            } placeholder: {
                ProgressView()
                    .frame(width: 100, height: 100)
            }
        }
    }
}

The code above is not enough to reproduce the error, if possible clone or download the project to visually understand what I'm saying about the delay of the images with the compiled project. The link on Github is:

https://github.com/brunosilva808/Sword


Solution

  • I downloaded your project and made changes to two files: ContentView and Cat

    Sword/Model/Cat:

    Added Identifiable protocol

    struct Cat: Codable, Equatable, Identifiable
    

    Sword/View/ContenView

    Added a simple cache using NSCache. This allows images that have already been downloaded to be stored in memory, avoiding repeated downloading and rendering when scrolling through the list.

    import SwiftUI
    
    // Global cache to store images that have been downloaded
    class ImageCache {
        static let shared = NSCache<NSString, UIImage>()
    }
    
    final class ViewModel: ObservableObject {
        private let apiService: APIService
        private var page = 0
        @Published var catsArray: [Cat] = []
        @Published var searchTerm = ""
        
        var filteredCatsArray: [Cat] {
            // Filters the cats array based on the search term if it's not empty
            guard !searchTerm.isEmpty else { return catsArray }
            return catsArray.filter { $0.breedName.localizedCaseInsensitiveContains(searchTerm) }
        }
        
        // Checks if the current cat is the last one in the list
        func isLastCat(id: String) -> Bool {
            return catsArray.last?.id == id
        }
        
        init(apiService: APIService = APIService()) {
            self.apiService = apiService
        }
        
        @MainActor
        func fetchCats() async {
            // Fetches a new batch of cat images from the API and appends them to the array
            catsArray.append(contentsOf: await apiService.fetchImages(.thumb, page: page, limit: 25))
            page += 1
        }
    }
    
    struct ContentView: View {
        @StateObject var viewModel = ViewModel() // Observes changes in the ViewModel
        private let columns = [ GridItem(.adaptive(minimum: 100)) ] // Adaptive grid layout
        
        var body: some View {
            NavigationView {
                ScrollView {
                    // LazyVGrid ensures that only visible items are loaded, improving scroll performance
                    LazyVGrid(columns: columns, spacing: 20) {
                        ForEach(viewModel.filteredCatsArray, id: \.id) { cat in
                            VStack {
                                // Custom view that handles loading images from cache or downloading them
                                CatImageView(url: cat.url)
                                Text(cat.breedName) // Displays the breed name
                            }
                            .onAppear {
                                // When the last cat in the list appears, fetch more data
                                if viewModel.isLastCat(id: cat.id) {
                                    Task {
                                        await viewModel.fetchCats()
                                    }
                                }
                            }
                        }
                    }
                    .padding()
                }
                .navigationTitle("Cats") // Title of the navigation bar
                .onAppear {
                    // Fetches the initial batch of cats when the view appears
                    Task {
                        await viewModel.fetchCats()
                    }
                }
            }
        }
    }
    
    // View to handle loading the image either from cache or by downloading it
    struct CatImageView: View {
        let url: String
        
        var body: some View {
            // Checks if the image is already in the cache
            if let cachedImage = ImageCache.shared.object(forKey: url as NSString) {
                // If cached, show the cached image
                Image(uiImage: cachedImage)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: 100, height: 100)
                    .clipped()
            } else {
                // Otherwise, use AsyncImage to download the image
                AsyncImage(url: URL(string: url)) { phase in
                    if let image = phase.image {
                        image
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(width: 100, height: 100)
                            .clipped()
                            .onAppear {
                                // Once downloaded, store the image in the cache
                                ImageCache.shared.setObject(image.asUIImage(), forKey: url as NSString)
                            }
                    } else if phase.error != nil {
                        // Show a red placeholder if there was an error
                        Color.red.frame(width: 100, height: 100)
                    } else {
                        // Show a progress view while the image is loading
                        ProgressView().frame(width: 100, height: 100)
                    }
                }
            }
        }
    }
    
    // Helper function to convert a SwiftUI Image to a UIImage for caching
    extension Image {
        func asUIImage() -> UIImage {
            let controller = UIHostingController(rootView: self)
            let view = controller.view
            
            let targetSize = CGSize(width: 100, height: 100)
            view?.bounds = CGRect(origin: .zero, size: targetSize)
            view?.backgroundColor = .clear
            
            let renderer = UIGraphicsImageRenderer(size: targetSize)
            return renderer.image { _ in
                view?.drawHierarchy(in: view!.bounds, afterScreenUpdates: true)
            }
        }
    }
    

    This modifications should make the scrolling smoother and reduce the need to re-download images when scrolling back up.

    Let me know how it performs!