Search code examples
swiftuicloudkit

SwiftUI and CloudKit asynchronous data loading


I'm fetching a large number of Tool objects from iCloud using CloudKit when my app's main screen appears, and I'm running these objects through a filter. The Tool array is stored in a ToolViewModel object toolVM inside an ObservableObject class named UserData.

This is my ViewModel and my View code:

class ToolViewModel: InstrumentViewModel {
    @Published var tools = [Tool]()

    func filter(searchString: String, showFavoritesOnly: Bool) -> [Tool] {
        let list = super.filter(searchString: searchString, showFavoritesOnly: showFavoritesOnly)
        return list.map{ $0 as! Tool }.sorted()
    }

    func cleanUpCategories(from category: String) {
        super.cleanUpCategories(instruments: tools, from: category)
    }
}

struct ToolList: View {
    // @ObservedObject var toolVM = ToolViewModel()
    @ObservedObject var userData: UserData
    @State private var searchString = ""
    @State private var showCancelButton: Bool = false

    var filteredTools: [Tool] {
        userData.toolVM.filter(searchString: searchString, showFavoritesOnly: userData.showFavoritesOnly)
    }

    var body: some View {
        NavigationView {
            VStack {
                // Search view
                SearchView(searchString: $searchString, showCancelButton: $showCancelButton)
                .padding(.horizontal)

                // Tool list
                List {
                    ForEach(filteredTools) { tool in
                        NavigationLink(destination: ToolDetail(tool: tool, userData: self.userData)) {
                            ToolRow(tool: tool)
                        }
                    } // ForEach ends
                    .onDelete(perform: onDelete)
                } // List ends
            } // VStack ends
            .navigationBarTitle("Tools")
        } // NavigationView ends
        .onAppear() {
            if self.userData.toolVM.tools.isEmpty {
                // Tools (probably) haven't been loaded yet (or are really empty), so try it
                self.userData.updateTools()
            }
        }
    }
    ...
}

This is the ViewModel's superclass (as I have two more similar ViewModels, I introduced this):

class InstrumentViewModel: ObservableObject {
    @Published var categories = [InstrumentCategory]()
    @Published var instrumentCategories = [String: [Instrument]]()

    func filter(searchString: String, showFavoritesOnly: Bool) -> [Instrument] {
        var list = [Instrument]()
        for category in categories {
            if category.isSelected && instrumentCategories[category.name] != nil {
                if searchString == "" {
                    list += showFavoritesOnly ? instrumentCategories[category.name]!.filter { $0.isFavorite } : instrumentCategories[category.name]!
                } else {
                    list += showFavoritesOnly ? instrumentCategories[category.name]!.filter { $0.isFavorite && $0.contains(searchString: searchString) } : instrumentCategories[category.name]!.filter { $0.contains(searchString: searchString) }
                }
            }
        }
        return list
    }

    func setInstrumentCategories(instruments: [Instrument]) {
        var categoryStrings = Set<String>()
        for instrument in instruments {
            categoryStrings.insert(instrument.category)
        }
        for categoryString in categoryStrings.sorted() {
            categories.append(InstrumentCategory(name: categoryString))
        }
    }
}

This is my UserData class:

final class UserData: ObservableObject {
    @Published var showFavoritesOnly = false

    @Published var toolVM = ToolViewModel()

    func updateTools() {
        DataHelper.loadFromCK(instrumentType: .tools) { (result) in
            switch result {
            case .success(let loadedInstruments):
                self.toolVM.tools = loadedInstruments as! [Tool]
                self.toolVM.setInstrumentCategories(instruments: loadedInstruments)
                self.toolVM.instrumentCategories = Dictionary(grouping: self.toolVM.tools, by: { $0.category })
                debugPrint("Successfully loaded instruments of type Tools and initialized categories and category dictionary")
            case .failure(let error):
                debugPrint(error.localizedDescription)
            }
        }
    }
}

And last but not least the helper class that actually loads the data from iCloud:

struct DataHelper {
    static func loadFromCK(instrumentType: InstrumentCKDataTypes, completion: @escaping (Result<[Instrument], Error>) -> ()) {
        let predicate = NSPredicate(value: true)
        let query = CKQuery(recordType: instrumentType.rawValue, predicate: predicate)
        getCKRecords(instrumentType: instrumentType, forQuery: query, completion: completion)
    }

    private static func getCKRecords(instrumentType: InstrumentCKDataTypes, forQuery query: CKQuery, completion: @escaping (Result<[Instrument], Error>) -> ()) {
        CKContainer.default().publicCloudDatabase.perform(query, inZoneWith: CKRecordZone.default().zoneID) { results, error in
            if let error = error {
                DispatchQueue.main.async { completion(.failure(error)) }
                return
            }
            guard let results = results else { return }
            switch instrumentType {
            case .tools:
                DispatchQueue.main.async { completion(.success(results.compactMap { Tool(record: $0) })) }
            case .drivers:
                DispatchQueue.main.async { completion(.success(results.compactMap { Driver(record: $0) })) }
            case .adapters:
                DispatchQueue.main.async { completion(.success(results.compactMap { Adapter(record: $0) })) }
            }
        }
    }
}

The issue I encounter is the following: The view initializes before the data was loaded from iCloud. I initialize the tools variable in the ViewModel with an empty Tool array. Therefore no tools are displayed when the view appears.

Although tools is a @Published variable, the view won't reload after the asynchronous iCloud loading process has finished. This is the behavior I would expect. As soon as I start typing some search string into the search field, the tools do appear. It's only about the very first loading.

What won't work either is to initialize the toolVM in the UserData initializer, as I won't get access to it from the asynchronous loading closure.

Funny enough: If I move the toolVM variable into the view itself as @ObservedObject (you can see it in my View, I commented this line out in the code), the view will reload after data load is done. Unfortunately this is not an option for me, as I need access to the toolVM ViewModel in other parts of the app, therefore I store it in my UserData class.

I assume it has something to do with the asynchronous loading.


Solution

  • ToolViewModel reference is not changed, so nothing published at UserData level. Here is possible solution - force publish intentionally:

    DataHelper.loadFromCK(instrumentType: .tools) { (result) in
        switch result {
        case .success(let loadedInstruments):
            self.toolVM.tools = loadedInstruments as! [Tool]
            self.toolVM.setInstrumentCategories(instruments: loadedInstruments)
            self.toolVM.instrumentCategories = Dictionary(grouping: self.toolVM.tools, by: { $0.category })
            debugPrint("Successfully loaded instruments of type Tools and initialized categories and category dictionary")
    
            self.objectWillChange.send()      // << this !!
    
        case .failure(let error):
            debugPrint(error.localizedDescription)
        }
    }
    

    -

    Alternate solution is to separate ToolViewModel dependent part of ToolList view into dedicated smaller child view with observed ToolViewModel and pass reference in it from userData.