skip to Main Content

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)
            }
        }
    }
}

https://github.com/brunosilva808/Sword

2

Answers


  1. .task replaces @StateObject so try removing that class. The basics are return cats as a result from your async func. Put this async func in a struct that is not main actor, eg CatsController which usually is an EnvironmentKey. Call it from .task and set the result on an @State.

    Use task(id:page) for pages.

    Login or Signup to reply.
  2. I downloaded your project and made changes to two files: ContentView and Cat

    Sword/Model/Cat:

    import Foundation
    
    struct Cat: Codable, Equatable, Identifiable {
        var breeds: [Breed]
        let id: String
        let url: String
    }
    
    extension Cat {
        var breedName: String {
            if let breed = breeds.first {
                return breed.name
            } else {
                return ""
            }
        }
    
        var temperament: String {
            if let breed = breeds.first {
                return breed.temperament
            } else {
                return ""
            }
        }
    
        var origin: String {
            if let breed = breeds.first {
                return breed.origin
            } else {
                return ""
            }
        }
    
        var description: String {
            if let breed = breeds.first {
                return breed.description
            } else {
                return ""
            }
        }
    }
    
    // MARK: - Breed
    struct Breed: Codable, Equatable {
        let id: String
        let name: String
        let temperament: String
        let origin: String
        let description: String
    }
    
    extension Cat {
        static let mockCats = [
            Cat(breeds: [Breed(id: "1", name: "American", temperament: "Soft", origin: "America", description: "It's a cat")], id: "1", url: "https://cdn2.thecatapi.com/images/Hb2N6tYTJ.jpg"),
            Cat(breeds: [Breed(id: "2", name: "Abyssinian", temperament: "Medium", origin: "Abyssinian", description: "It's a cat")], id: "2", url: "https://cdn2.thecatapi.com/images/Hb2N6tYTJ.jpg"),
            Cat(breeds: [Breed(id: "3", name: "Balinese", temperament: "Hard", origin: "Balinese", description: "It's a cat")], id: "3", url: "https://cdn2.thecatapi.com/images/Hb2N6tYTJ.jpg")]
    }
    

    Sword/View/ContenView

    import SwiftUI
    
    final class ViewModel: ObservableObject {
        private let apiService: APIService
        private var page = 0
        @Published var catsArray: [Cat] = []
        @Published var searchTerm = ""
    
        var filteredCatsArray: [Cat] {
            guard !searchTerm.isEmpty else { return catsArray }
            return catsArray.filter{$0.breedName.localizedCaseInsensitiveContains(searchTerm)}
        }
    
        func isLastCat(id: String) -> Bool {
            return catsArray.last?.id == id ? true : false
        }
    
        init(apiService: APIService = APIService()) {
            self.apiService = apiService
        }
    
        @MainActor
        func fetchCats() async {
            catsArray.append(contentsOf: await apiService.fetchImages(.thumb, page: page, limit: 25))
            page += 1
        }
    }
    
    struct ContentView: View {
    
        @StateObject var viewModel = ViewModel()
        private let columns = [ GridItem(.adaptive(minimum: 100)) ]
    
        var body: some View {
            NavigationView {
                ScrollView {
                    LazyVGrid(columns: columns, spacing: 20) {
                        ForEach(viewModel.filteredCatsArray, id: .id) { cat in
                            VStack {
                                AsyncImage(url: URL(string: cat.url)) { phase in
                                    if let image = phase.image {
                                        image
                                            .resizable()
                                            .aspectRatio(contentMode: .fill)
                                            .frame(width: 100, height: 100)
                                            .clipped()
                                    } else if phase.error != nil {
                                        Color.red.frame(width: 100, height: 100) // Error placeholder
                                    } else {
                                        ProgressView().frame(width: 100, height: 100) // Loading placeholder
                                    }
                                }
                                Text(cat.breedName)
                            }
                            .onAppear {
                                if viewModel.isLastCat(id: cat.id) {
                                    Task {
                                        await viewModel.fetchCats()
                                    }
                                }
                            }
                        }
                    }
                    .padding()
                }
                .navigationTitle("Cats")
                .onAppear {
                    Task {
                        await viewModel.fetchCats()
                    }
                }
            }
        }
    }
    

    The final result is better

    But

    I recommend adding 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!

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search