iosswiftswiftuiphasset

Best way to fetch and display all images from Photos SwiftUI


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.


Solution

  • 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"