I have created a way to fetch all photos and display them in a grid within a view. Upon loading the app, all seems fine, everything in the view loads properly and if I scroll through the scrollview, more continue to load.
However, as I carry on scrolling, they stop suddenly loading and just display the ProgressView()
that is in place whilst the image is being fetched. Furthermore, if I scroll back up to the previously perfectly loaded pictures, they seem to be have cleared from memory and they now too are just showing the ProgressView()
and no longer show the image.
I have a feeling this is some sort of memory issue with the iPhone, however I think I have the best practice for loading and caching images?
This is the PhotoThumbnailView
that is shown in the grid within the main page of my app:
struct PhotoThumbnailView: View {
@State private var image: Image?
@EnvironmentObject var photoLibraryService: PhotoLibraryService
private var assetLocalId: String
init(assetLocalId: String) {
self.assetLocalId = assetLocalId
}
var body: some View {
ZStack {
// Show the image if it's available
if let image = image {
GeometryReader { proxy in
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(
width: proxy.size.width,
height: proxy.size.width
)
.clipped()
}
.aspectRatio(1, contentMode: .fit)
} else {
Rectangle()
.foregroundColor(.gray)
.aspectRatio(1, contentMode: .fit)
ProgressView()
}
}
.task {
await loadImageAsset()
}
.onDisappear {
image = nil
}
}
}
extension PhotoThumbnailView {
func loadImageAsset(
targetSize: CGSize = PHImageManagerMaximumSize
) async {
guard let uiImage = try? await photoLibraryService.fetchImage(byLocalIdentifier: assetLocalId,targetSize: targetSize) else {
print("unable to get image")
image = nil
return
}
image = Image(uiImage: uiImage)
}
}
This is the PhotoLibraryService
object I am referencing to load all images and cache them, and also allow to load a higher-res photo once clicked:
class PhotoLibraryService: ObservableObject {
@Published var results = PHFetchResultCollection(fetchResult: .init())
var imageCachingManager = PHCachingImageManager()
var authorizationStatus: PHAuthorizationStatus = .notDetermined
@Published var errorString : String = ""
init() {
PHPhotoLibrary.requestAuthorization { (status) in
self.authorizationStatus = status
switch status {
case .authorized:
self.errorString = ""
self.fetchAllPhotos()
case .denied, .restricted, .limited:
self.errorString = "Photo access permission denied"
case .notDetermined:
self.errorString = "Photo access permission not determined"
@unknown default:
fatalError()
}
}
}
private func fetchAllPhotos() {
imageCachingManager.allowsCachingHighQualityImages = false
let fetchOptions = PHFetchOptions()
fetchOptions.includeHiddenAssets = false
fetchOptions.sortDescriptors = [
NSSortDescriptor(key: "creationDate", ascending: true)
]
// Fetch all images and store in photos to perform extra code
// on in the background thread
DispatchQueue.global().async {
let photos = PHAsset.fetchAssets(with: .image, options: fetchOptions)
photos.enumerateObjects({asset, _, _ in
// <extra code not relevant to this question>
})
// Update main thread and UI
DispatchQueue.main.async {
self.results.fetchResult = photos
}
}
}
func fetchImage(byLocalIdentifier localId: String, targetSize: CGSize = PHImageManagerMaximumSize, contentMode: PHImageContentMode = .default) async throws -> UIImage? {
let results = PHAsset.fetchAssets(withLocalIdentifiers: [localId], options: nil)
guard let asset = results.firstObject else {throw PHPhotosError(_nsError: NSError(domain: "Photo Error", code: 2))}
let options = PHImageRequestOptions()
options.deliveryMode = .opportunistic
options.resizeMode = .fast
options.isNetworkAccessAllowed = true
options.isSynchronous = true
return try await withCheckedThrowingContinuation { [weak self] continuation in
/// Use the imageCachingManager to fetch the image
self?.imageCachingManager.requestImage(
for: asset,
targetSize: targetSize,
contentMode: contentMode,
options: options,
resultHandler: { image, info in
/// image is of type UIImage
if let error = info?[PHImageErrorKey] as? Error {
print(error)
continuation.resume(throwing: error)
return
}
continuation.resume(returning: image)
}
)
}
}
}
Overall, with this code, it works but is very clunky, not what you'd want when viewing photos. Im almost certain it's some sort of memory issue, but upon debugging, it's not even taking that much memory and all behaviour is making it hard to debug.
Are you by chance trying to follow the tutorial at https://codewithchris.com/photo-gallery-app-swiftui/ like I am?
I encounter the same issue when doing what you're doing, but have noticed that when I replace all instances of "PHImageManagerMaximumSize" with something smaller (e.g. "CGSize(width: 300, height: 300)"), things work better, although still not perfectly. More specifically, when I do that, all of the images will at least continue to load while I scroll. I'd really like to limit the amount of gray loading boxes that I see, though, since I never really see that in any other apps on the app store :/
One thing I'm currently wondering is if a combination of using a smaller CGSize and leveraging the "startCachingImages" and "stopCachingImages" methods of PHCachingImageManager to continually preheat the cache for the next photos beyond our current position in the ScrollView, and stop caching any photos that are no longer near our current position in the ScrollView. But this is just a hunch based on what I read here:
"If you need to load image data for many assets together, use the PHCachingImageManager class to “preheat” the cache by loading images you expect to need soon. For example, when populating a collection view with photo asset thumbnails, you can cache images ahead of the current scroll position"