0

I'm learning Swift/SwiftUI by building a photo organizer app. It displays a user's photo library in a grid like the built-in photos app, and there's a detail view where you can do things like favorite a photo or add it to the trash.

My app loads all the data and displays it fine, but the UI doesn't update when things change. I've debugged enough to confirm that my edits are applied to the underlying PHAssets and Core Data assets. It feels like the problem is that my views aren't re-rendering.

I used Dave DeLong's approach to create an abstraction layer that separates Core Data from SwiftUI. I have a singleton environment object called DataStore that handles all interaction with Core Data and the PHPhotoLibrary. When the app runs, the DataStore is created. It makes an AssetFetcher that grabs all assets from the photo library (and implements PHPhotoLibraryChangeObserver). DataStore iterates over the assets to create an index in Core Data. My views' viewmodels query core data for the index items and display them using the @Query property wrapper from Dave's post.

App.swift

@main
struct LbPhotos2App: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(\.dataStore, DataStore.shared)
        }
    }
}

PhotoGridView.swift (this is what contentview presents)

struct PhotoGridView: View {
    @Environment(\.dataStore) private var dataStore : DataStore
    @Query(.all) var indexAssets: QueryResults<IndexAsset>
    @StateObject var vm = PhotoGridViewModel() 
    
    func updateVm() {
        vm.createIndex(indexAssets)
    }
    
    var body: some View {
        GeometryReader { geo in
            VStack {
                HStack {
                    Text("\(indexAssets.count) assets")
                    Spacer()
                    TrashView()
                }.padding(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
                ScrollView {
                    ForEach(vm.sortedKeys, id: \.self) { key in
                        let indexAssets = vm.index[key]
                        let date = indexAssets?.first?.creationDate
                        GridSectionView(titleDate:date, indexAssets:indexAssets!, geoSize: geo.size)
                    }
                }.onTapGesture {
                    updateVm()
                }
            }.onAppear {
                updateVm()
            }
            .navigationDestination(for: IndexAsset.self) { indexAsset in
                AssetDetailView(indexAsset: indexAsset)
            }
        }
    }
    
}

PhotoGridViewModel.swift

class PhotoGridViewModel: ObservableObject {
    @Published var index: [String:[IndexAsset]] = [:]
    var indexAssets: QueryResults<IndexAsset>?
        
    func createIndex() {
        guard let assets = self.indexAssets else {return}
        self.createIndex(assets)
    }
    
    func createIndex(_ queryResults: QueryResults<IndexAsset>) {
        indexAssets = queryResults
        if queryResults.count > 0 {
            var lastDate = Date.distantFuture
            
            for i in 0..<queryResults.count {
                let item = queryResults[i]
                let isSameDay = isSameDay(firstDate: lastDate, secondDate: item.creationDate!)
                if isSameDay {
                    self.index[item.creationDateKey!]?.append(item)
                } else {
                    self.index[item.creationDateKey!] = [item]
                }
                lastDate = item.creationDate!
            }
        }
        self.objectWillChange.send()
        
    }
    
    var sortedKeys: [String] {
        return index.keys.sorted().reversed()
    }
    
    private func isSameDay(firstDate:Date, secondDate:Date) -> Bool {
        return Calendar.current.isDate(
            firstDate,
            equalTo: secondDate,
            toGranularity: .day
        )
    }

 }

Here's where I actually display the asset in GridSectionView.swift

LazyVGrid(columns: gridLayout, spacing: 2) {
                let size = geoSize.width/4

                ForEach(indexAssets, id:\.self) { indexAsset in
                    NavigationLink(
                        value: indexAsset,
                        label: {
                            AssetCellView(indexAsset: indexAsset, geoSize:geoSize)
                        }
                    ).frame(width: size, height: size)
                        .buttonStyle(.borderless)
                }
            }

AssetCellView.swift

struct AssetCellView: View {
    @StateObject var vm : AssetCellViewModel
    var indexAsset : IndexAsset
    var geoSize : CGSize
    
    init(indexAsset: IndexAsset, geoSize: CGSize) {
        self.indexAsset = indexAsset
        self.geoSize = geoSize
        _vm = StateObject(wrappedValue: AssetCellViewModel(indexAsset: indexAsset, geoSize: geoSize))
    }
    
    
    var body: some View {
        ZStack(alignment: .bottomTrailing) {
            if (vm.indexAsset != nil && vm.image != nil) {
                vm.image?
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .border(.blue, width: vm.indexAsset!.isSelected ? 4 : 0)
            }
            if (vm.indexAsset != nil && vm.indexAsset!.isFavorite) {
                Image(systemName:"heart.fill")
                    .resizable()
                    .frame(width: 20, height: 20)
                    .foregroundStyle(.ultraThickMaterial)
                    .shadow(color: .black, radius: 12)
                    .offset(x:-8, y:-8)
            }
        }
        
    }
}

AssetCellViewModel.swift
class AssetCellViewModel: ObservableObject{
    @Environment(\.dataStore) private var dataStore
    @Published var image : Image?
    var indexAsset : IndexAsset?
    var geoSize : CGSize
    
    init(indexAsset: IndexAsset? = nil, geoSize:CGSize) {
        self.indexAsset = indexAsset
        self.geoSize = geoSize
        self.requestImage(targetSize: CGSize(width: geoSize.width/4, height: geoSize.width/4))
    }
    
    func setIndexAsset(_ indexAsset:IndexAsset, targetSize: CGSize) {
        self.indexAsset = indexAsset
        self.requestImage(targetSize: targetSize)
    }
    
    func requestImage(targetSize: CGSize? = nil) {
        if (self.indexAsset != nil) {
            dataStore.fetchImageForLocalIdentifier(
                id: indexAsset!.localIdentifier!,
                targetSize: targetSize,
                completionHandler: { image in
                    withAnimation(Animation.easeInOut (duration:0.15)) {
                        self.image = image
                    }
                }
            )
        }
    }
}

some of DataStore.swift

public class DataStore : ObservableObject {
    static let shared = DataStore()
    
    let persistenceController = PersistenceController.shared
    @ObservedObject var assetFetcher = AssetFetcher() 
    
    let dateFormatter = DateFormatter()
    var imageManager = PHCachingImageManager()
    let id = UUID().uuidString

    
    init() {
        print(" init dataStore: \(self.id)")        
        dateFormatter.dateFormat = "yyyy-MM-dd"
        assetFetcher.iterateResults{ asset in
            do {
                try self.registerAsset(
                    localIdentifier: asset.localIdentifier,
                    creationDate: asset.creationDate!,
                    isFavorite: asset.isFavorite
                )
            } catch {
                print("Error registering asset \(asset)")
            }
        }
    }

    func registerAsset(localIdentifier:String, creationDate:Date, isFavorite:Bool) throws {
        let alreadyExists = indexAssetEntityWithLocalIdentifier(localIdentifier)
        if alreadyExists != nil {
//            print(" Asset already registered: \(localIdentifier)")
//            print(alreadyExists![0])
            return
        }
        
        let iae = IndexAssetEntity(context: self.viewContext)
        iae.localIdentifier = localIdentifier
        iae.creationDate = creationDate
        iae.creationDateKey = dateFormatter.string(from: creationDate)
        iae.isFavorite = isFavorite
        iae.isSelected = false
        iae.isTrashed = false
        
        self.viewContext.insert(iae)
        try self.viewContext.save()
        print(" Registered asset: \(localIdentifier)")
    }

And AssetFetcher.swift

class AssetFetcher:NSObject, PHPhotoLibraryChangeObserver, ObservableObject {
    @Published var fetchResults : PHFetchResult<PHAsset>? = nil 
    let id = UUID().uuidString

    override init() {
        super.init()
        print(" init assetfetcher: \(id)")
        self.startFetchingAllPhotos()
    }
        
    deinit {
        PHPhotoLibrary.shared().unregisterChangeObserver(self)
    }
       
    func startFetchingAllPhotos() {
        getPermissionIfNecessary(completionHandler: {result in
            print(result)
        })
        let fetchOptions = PHFetchOptions()
        var datecomponents = DateComponents()
        datecomponents.month = -3
        
        //TODO: request assets dynamically
        let threeMonthsAgo = Calendar.current.date(byAdding: datecomponents, to:Date())
        
        fetchOptions.predicate = NSPredicate(format: "creationDate > %@ AND creationDate < %@", threeMonthsAgo! as NSDate, Date() as NSDate)
        fetchOptions.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        fetchOptions.wantsIncrementalChangeDetails = true
        //        fetchOptions.fetchLimit = 1000
        let results = PHAsset.fetchAssets(with: .image, options: fetchOptions)
        PHPhotoLibrary.shared().register(self)
        print(" \(PHPhotoLibrary.shared())")
        self.fetchResults = results
    }
    
    func iterateResults(_ callback:(_ asset: PHAsset) -> Void) {
        print("iterateResults")
        guard let unwrapped = self.fetchResults else {
            return
        }
        for i in 0..<unwrapped.count {
            callback(unwrapped.object(at: i))
        }
    }

    
    func photoLibraryDidChange(_ changeInstance: PHChange) {
        print(" photoLibraryDidChange")
        DispatchQueue.main.async {
            if let changeResults = changeInstance.changeDetails(for: self.fetchResults!) {
                self.fetchResults = changeResults.fetchResultAfterChanges
//                self.dataStore.photoLibraryDidChange(changeInstance)
//                self.updateImages()
                self.objectWillChange.send()
            }
        }
    }
    
}
Jake Z
  • 105
  • 7
  • Have you used the debugger to check that all the functions you're expecting to get called are in fact called? – jnpdx Jul 06 '22 at 21:13
  • If printing everything counts as using the debugger, then yes. I have confirmed that my edits are applied to the underlying PHAssets and Core Data entities. Also confirmed that the AssetFetcher is getting notified when the library changes, and I'm fairly sure that my Queries refresh when core data changes. – Jake Z Jul 06 '22 at 21:15
  • @ObservedObject Does not work in a class – lorem ipsum Jul 06 '22 at 22:06
  • @loremipsum that was a recent change — I had it as StateObject, but the assetfetcher never seemed to initialize – Jake Z Jul 06 '22 at 22:11
  • `@StateObject` doesn't work either. The only 2 wrappers that work in a `class` are `@Published` (Inside an `ObservableObject`) and `@AppStorage`. You can't chain `ObservableObject`s. You can use `sink` to get updates but the wrappers don't work. – lorem ipsum Jul 06 '22 at 22:36

0 Answers0