diff --git a/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift b/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift index fc79b5bd..ad627d35 100644 --- a/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift +++ b/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift @@ -43,6 +43,9 @@ open class Carousel: View { /// The models for the molecules. public var molecules: [MoleculeModelProtocol & CarouselItemModelProtocol]? + /// A list of currently registered cells. + public var registeredMoleculeIds: [String]? + /// The horizontal alignment of the cell in the collection view. Only noticeable if the itemWidthPercent is less than 100%. public var itemAlignment = UICollectionView.ScrollPosition.left @@ -174,9 +177,7 @@ open class Carousel: View { MVMCoreLoggingHandler.shared()?.handleDebugMessage("[\(Self.self)] [\(ObjectIdentifier(self).hashValue)]\noriginal model: \(originalModel?.debugDescription ?? "none")\nnew model: \(model)") if #available(iOS 15.0, *) { - if let originalModel, carouselModel.isDeeplyVisuallyEquivalent(to: originalModel), - originalModel.visibleMolecules.isVisuallyEquivalent(to: molecules ?? []) // Since the carousel model's children are in place replaced and we do not have a deep copy of this model tree, add in this hack to check if the prior captured carousel items match the newly visible ones. - { + if hasSameCellRegistration(with: carouselModel, delegateObject: delegateObject) { // Prevents a carousel reset while still updating the cell backing data through reconfigureItems. MVMCoreLoggingHandler.shared()?.handleDebugMessage("[\(Self.self)] Model is visually equivalent. Skipping rebuild...") prepareMolecules(with: carouselModel) @@ -284,12 +285,29 @@ open class Carousel: View { /// Registers the cells with the collection view func registerCells(with carouselModel: CarouselModel, delegateObject: MVMCoreUIDelegateObject?) { - + var registeredIds = [String]() for molecule in carouselModel.molecules { if let info = getMoleculeInfo(with: molecule, delegateObject: delegateObject) { collectionView.register(info.class, forCellWithReuseIdentifier: info.identifier) + registeredIds.append(info.identifier) + } else { + registeredIds.append(molecule.moleculeName) } } + registeredMoleculeIds = registeredIds + } + + func hasSameCellRegistration(with carouselModel: CarouselModel, delegateObject: MVMCoreUIDelegateObject?) -> Bool { + guard let registeredMoleculeIds else { return false } + + let incomingIds = carouselModel.molecules.map { molecule in + if let info = getMoleculeInfo(with: molecule, delegateObject: delegateObject) { + return info.identifier + } else { + return molecule.moleculeName + } + } + return incomingIds == registeredMoleculeIds } //-------------------------------------------------- diff --git a/MVMCoreUI/BaseControllers/ViewController.swift b/MVMCoreUI/BaseControllers/ViewController.swift index 78191da5..a6c43b2d 100644 --- a/MVMCoreUI/BaseControllers/ViewController.swift +++ b/MVMCoreUI/BaseControllers/ViewController.swift @@ -8,6 +8,7 @@ import UIKit import MVMCore +import Combine @objc open class ViewController: UIViewController, MVMCoreViewControllerProtocol, MVMCoreViewManagerViewControllerProtocol, MoleculeDelegateProtocol, FormHolderProtocol, MVMCoreActionDelegateProtocol, ActionDelegateProtocol, MVMCoreLoadDelegateProtocol, UITextFieldDelegate, UITextViewDelegate, ObservingTextFieldDelegate, MVMCoreUIDetailViewProtocol, PageProtocol, PageBehaviorHandlerProtocol { @@ -38,7 +39,7 @@ import MVMCore public var behaviors: [PageBehaviorProtocol]? public var needsUpdateUI = false - private var observingForResponses: NSObjectProtocol? + private var observingForResponses: AnyCancellable? private var initialLoadFinished = false public var isFirstRender = true public var previousScreenSize = CGSize.zero @@ -66,9 +67,28 @@ import MVMCore (pagesToListenFor()?.count ?? 0 > 0 || modulesToListenFor()?.count ?? 0 > 0) else { return } - observingForResponses = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil, queue: pageUpdateQueue) { [weak self] notification in - self?.responseJSONUpdated(notification: notification) - } + observingForResponses = NotificationCenter.default.publisher(for: NSNotification.Name(rawValue: NotificationResponseLoaded)) + .receive(on: self.pageUpdateQueue) // Background serial queue. + .compactMap { [weak self] notification in + self?.pullUpdates(from: notification) ?? nil + } + // Merge all page and module updates into one update event. + .scan((nil, nil, nil)) { accumulator, next in + // Always take the latest page and the latest modules with same key. + return (next.0 ?? accumulator.0, next.1 ?? accumulator.1, next.2?.mergingRight(accumulator.2 ?? [:])) + } + // Delay allowing the previous model update to settle before triggering a re-render. + .throttle(for: .seconds(0.05), scheduler: RunLoop.main, latest: true) + .sink { [weak self] (pageUpdates: [String : Any]?, pageModel: PageModelProtocol?, moduleUpdates: [String : Any]?) in + guard let self = self else { return } + if let pageUpdates, pageModel != nil { + self.loadObject?.pageJSON = pageUpdates + } + let mergedModuleUpdates = (loadObject?.modulesJSON ?? [:]).mergingLeft(moduleUpdates ?? [:]) + self.loadObject?.modulesJSON = mergedModuleUpdates + self.debugLog("Applying async update page model \(pageModel.debugDescription) and modules \(mergedModuleUpdates.keys) to page.") + self.handleNewData(pageModel) + } } open func stopObservingForResponseJSONUpdates() { @@ -77,6 +97,31 @@ import MVMCore self.observingForResponses = nil } + func pullUpdates(from notification: Notification) -> (pageUpdates: [String : Any]?, pageModel: PageModelProtocol?, moduleUpdates: [String : Any]?)? { + // Get the page data. + let pageUpdates = extractInterestedPageType(from: notification.userInfo?.optionalDictionaryForKey(KeyPageMap) ?? [:]) + // Convert the page data into a new model. + var pageModel: PageModelProtocol? = nil + if let pageUpdates { + do { + // TODO: Rewiring to parse from plain JSON rather than this protocol indirection. + pageModel = try (self as? any TemplateProtocol & PageBehaviorHandlerProtocol & MVMCoreViewControllerProtocol)?.parseTemplate(pageJSON: pageUpdates) + } catch { + if let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: "updateJSON for pageType: \(String(describing: self.pageType))") { + MVMCoreLoggingHandler.shared()?.addError(toLog: coreError) + } + } + } + // Get the module data. + let moduleUpdates = extractInterestedModules(from: notification.userInfo?.optionalDictionaryForKey(KeyModuleMap) ?? [:]) + debugLog("Receiving page \(pageModel?.pageType ?? "none") & \(moduleUpdates?.keys.description ?? "none") modules from \((notification.userInfo?["MVMCoreLoadObject"] as? MVMCoreLoadObject)?.requestParameters?.url?.absoluteString ?? "")") + + guard (pageUpdates != nil && pageModel != nil) || (moduleUpdates != nil && moduleUpdates!.count > 0) else { return nil } + + // Bundle the transformations. + return (pageUpdates, pageModel, moduleUpdates) + } + open func pagesToListenFor() -> [String]? { guard let pageType = loadObject?.pageType else { return nil } return [pageType] @@ -88,51 +133,22 @@ import MVMCore return requestModules + behaviorModules } - @objc open func responseJSONUpdated(notification: Notification) { - // Checks for a page we are listening for. - var hasDataUpdate = false - var pageModel: PageModelProtocol? = nil - if let pagesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyPageMap), - let loadObject, - let pageType = pagesToListenFor()?.first(where: { (pageTypeListened) -> Bool in - guard let page = pagesLoaded.optionalDictionaryForKey(pageTypeListened), + private func extractInterestedPageType(from pageMap: [String: Any]) -> [String: Any]? { + guard let pageType = pagesToListenFor()?.first(where: { pageTypeListened -> Bool in + guard let page = pageMap.optionalDictionaryForKey(pageTypeListened), let pageType = page.optionalStringForKey(KeyPageType), pageType == pageTypeListened else { return false } - return true - }) { - hasDataUpdate = true - loadObject.pageJSON = pagesLoaded.optionalDictionaryForKey(pageType) - - // Separate page updates from the module updates to avoid unecessary resets to behaviors and full re-renders. - do { - pageModel = try parsePageJSON(loadObject: loadObject) - } catch { - if let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: "updateJSON for pageType: \(String(describing: pageType))") { - MVMCoreLoggingHandler.shared()?.addError(toLog: coreError) - } - } + }) else { return nil } + return pageMap.optionalDictionaryForKey(pageType) + } + + private func extractInterestedModules(from moduleMap: [String: Any]) -> [String: Any]? { + guard let modulesListened = modulesToListenFor() else { return nil } + return moduleMap.filter { (key: String, value: Any) in + modulesListened.contains { $0 == key } } - - // Checks for modules we are listening for. - if let modulesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyModuleMap), - let modulesListened = modulesToListenFor() { - for moduleName in modulesListened { - if let module = modulesLoaded.optionalDictionaryForKey(moduleName) { - hasDataUpdate = true - var currentModules = loadObject?.modulesJSON ?? [:] - currentModules.updateValue(module, forKey: moduleName) - loadObject?.modulesJSON = currentModules - } - } - } - - guard hasDataUpdate else { return } - - MVMCoreDispatchUtility.performBlock(onMainThread: { - self.handleNewData(pageModel) - }) } open func shouldFinishProcessingLoad(_ loadObject: MVMCoreLoadObject, error: AutoreleasingUnsafeMutablePointer) -> Bool {