I am building an app that is mostly not a grid view. However there is one screen that is a grid view and because I need the extra preloading performance in order to prioritize fast scrolling image loading properly.
As such my app is mostly implemented in SwiftUI.
This is just about all you need to see of the view model
@MainActor
@Observable
class WeakImage {
weak var image: UIImage?
init(image: UIImage? = nil) {
self.image = image
}
}
@MainActor
@Observable
final class LibraryViewModel {
let libraryMetadata: LibraryMetadata = LibraryMetadata()
@ObservationIgnored let imageCache: ImageCache
private var loadedImages: [ImageAtLevel: WeakImage] = [:]
func bestAvailableImage(for id: ImageId) -> WeakImage? {
if let grid = loadedImages[ImageAtLevel(id: id, level: .grid)] {
return grid
}
if let thumbnail = loadedImages[ImageAtLevel(id: id, level: .thumbnail)] {
return thumbnail
}
return nil
}
// Called during preload
func loadImage(id: ImageId, level: ImageCache.ArtifactLevel) async {
let image = try await imageCache.get(for: id, level: level)
loadedImages[imageAtLevel] = WeakImage(image: image)
}
}
The intention is that every view shares this LibraryViewModel
. When images need preloading loadImage is called. The cache is free to free up images at will so I am using WeakImage
.
The problem that I am having is in the reusable cell of my UIKit view. I cannot seem to get it to observe these @Observable
objects correctly.
struct PhotosGrid: UIViewControllerRepresentable {
let libraryViewModel: LibraryViewModel
func updateUIViewController(_ photosGridViewController: PhotosGridUIViewController, context: Context) {
photosGridViewController.libraryViewModel = libraryViewModel
photosGridViewController.order = libraryViewModel.libraryMetadata.order
}
func makeUIViewController(context: Context) -> PhotosGridUIViewController {
let viewController = PhotosGridUIViewController()
viewController.libraryViewModel = libraryViewModel
return viewController
}
}
class PhotosGridUIViewController: UIViewController, UICollectionViewDataSource, ... {
var libraryViewModel: LibraryViewModel?
var order: [ImageId] = [] {
didSet {
DispatchQueue.main.async { [weak self] in
self?.collectionView.reloadData()
}
}
}
lazy var collectionView: UICollectionView = {
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
...
collectionView.dataSource = self
collectionView.delegate = self
return collectionView
}()
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(collectionView)
... (pinned to superview bounds)
collectionView.register(UIPhotoGridCell.self, forCellWithReuseIdentifier: PhotosGridUIViewController.reuseId)
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
return order.count
}
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PhotosGridUIViewController.reuseId, for: indexPath) as? UIPhotoGridCell else {
fatalError("Could not dequeue cell")
}
cell.model.libraryViewModel = libraryViewModel
cell.model.id = order[indexPath.row]
return cell
}
}
// I dont really need this object. Its just here to make this observable
@Observable
class UIPhotoGridCellModel {
var id: ImageId? = nil
var libraryViewModel: LibraryViewModel? = nil
}
class UIPhotoGridCell: UICollectionViewCell {
var model: UIPhotoGridCellModel = UIPhotoGridCellModel()
let imageView = {
let imageView = UIImageView()
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleAspectFit
return imageView
}()
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(imageView)
... (Pin to superview)
withObservationTracking {
// Never notices that model.id changed!
if let id = model.id {
_ = model.libraryViewModel?.bestAvailableImage(for: id)?.image
}
} onChange: {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if let id = self.model.id {
imageView.image = model.libraryViewModel?.bestAvailableImage(for: id)?.image
}
}
}
}
}
And then of course the SwiftUI view that uses it
struct ContentView: View {
@State var libraryViewModel = LibraryViewModel()
var body: some View {
VStack {
PhotosGrid(libraryViewModel: libraryViewModel)
.task {
await libraryViewModel.refresh()
}
}
}
}
That is quite a bit of code but essentially at a high level:
LibraryViewModel
holds thelibraryMetadata
and theimageCache
and the pertinentbestAvailableImage
method being used.ContentView
holds theLibraryViewModel
as@State
PhotosGrid
is aUIViewControllerRepresentable
holding thePhotosGridUIViewController
. It passes inLibraryViewModel
in itsmakeUIViewController
andupdateUIViewController
.PhotosGridUIViewController
uses reusableUIPhotoGridCell's
. When the cells are set up incellForItemAt
they get their id andlibraryViewModel
passed into them.- The
UIPhotoGridCell
attempts to usewithObservationTracking
to track changes to the bestAvailableImage result inLibraryViewModel
.
This unfortunately does not work. None of the images on the first screen notice they even got a thumbnail (although when you scroll they do) and none of them update when a better version is available.
I put a breakpoint in withObservationTracking
and the withObservationTracking
never calls to notice that model.id changed. I dont understand why it wouldnt.
If you use a SwiftUI view it does update as these images update properly so it is something about the way I am hosting a UIKit view here.