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:
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!