From f37e7abcb176f94a3050c2a0c2f326fd2026681a Mon Sep 17 00:00:00 2001 From: "Hedden, Kyle Matthew" Date: Mon, 13 May 2024 14:46:35 -0400 Subject: [PATCH] Digital PCT265 story PCT-135: Inline replacement updates with the core render loop. --- .../Accessibility/AccessibilityHandler.swift | 3 +- .../CarouselIndicatorModel.swift | 12 ++ .../Atomic/Organisms/Carousel/Carousel.swift | 8 +- .../Organisms/Carousel/CarouselModel.swift | 8 + .../ParentMoleculeModelProtocol.swift | 32 +++- .../TemplateModelProtocol.swift | 2 + .../Atomic/Protocols/TemplateProtocol.swift | 23 ++- .../Atomic/Templates/BaseTemplateModel.swift | 3 + .../Atomic/Templates/CollectionTemplate.swift | 9 +- .../Templates/ModalMoleculeListTemplate.swift | 4 +- .../ModalMoleculeStackTemplate.swift | 4 +- .../Templates/MoleculeListTemplate.swift | 10 +- .../Templates/MoleculeStackTemplate.swift | 9 +- .../Atomic/Templates/ThreeLayerTemplate.swift | 5 +- .../BaseControllers/ViewController.swift | 156 +++++++++++++----- .../Behaviors/PollingBehaviorModel.swift | 5 +- .../Protocols/PageBehaviorProtocol.swift | 8 +- .../ReplaceableMoleculeBehaviorModel.swift | 117 ++++++------- 18 files changed, 266 insertions(+), 152 deletions(-) diff --git a/MVMCoreUI/Accessibility/AccessibilityHandler.swift b/MVMCoreUI/Accessibility/AccessibilityHandler.swift index 65ae8272..76e556e6 100644 --- a/MVMCoreUI/Accessibility/AccessibilityHandler.swift +++ b/MVMCoreUI/Accessibility/AccessibilityHandler.swift @@ -261,8 +261,9 @@ open class AccessibilityHandlerBehavior: PageVisibilityBehavior, PageMoleculeTra accessibilityHandler = AccessibilityHandler.shared() //Protocol Mandatory init method. } - open func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { + open func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) -> [MoleculeModelProtocol]? { accessibilityHandler?.onPageNew(rootMolecules: rootMolecules, delegateObject) + return nil } open func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) { diff --git a/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/CarouselIndicatorModel.swift b/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/CarouselIndicatorModel.swift index 8aa1ca48..d793dc98 100644 --- a/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/CarouselIndicatorModel.swift +++ b/MVMCoreUI/Atomic/Atoms/Views/CarouselIndicator/CarouselIndicatorModel.swift @@ -145,4 +145,16 @@ open class CarouselIndicatorModel: CarouselPagingModelProtocol, MoleculeModelPro && indicatorColor == model.indicatorColor && position == model.position } + + public func isVisuallyEquivalent(to model: any MoleculeModelComparisonProtocol) -> Bool { + guard let model = model as? Self else { return false } + return backgroundColor == model.backgroundColor + && animated == model.animated + && hidesForSinglePage == model.hidesForSinglePage + && accessibilityHasSlidesInsteadOfPage == model.accessibilityHasSlidesInsteadOfPage + && enabled == model.enabled + && inverted == model.inverted + && disabledIndicatorColor == model.disabledIndicatorColor + && indicatorColor == model.indicatorColor + } } diff --git a/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift b/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift index fdd7f5b6..1db78373 100644 --- a/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift +++ b/MVMCoreUI/Atomic/Organisms/Carousel/Carousel.swift @@ -171,14 +171,20 @@ open class Carousel: View { guard let carouselModel = model as? CarouselModel else { return } + 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.isVisuallyEquivalent(to: originalModel) { - collectionView.reconfigureItems(at: collectionView.indexPathsForVisibleItems) + // Prevents a carousel reset while still updating the cell backing data through reconfigureItems. + MVMCoreLoggingHandler.shared()?.handleDebugMessage("[\(Self.self)] Model is visually equivalent. Skipping rebuild...") FormValidator.setupValidation(for: carouselModel, delegate: delegateObject?.formHolderDelegate) + pagingView?.currentIndex = originalModel.index // Trigger a paging view render. + collectionView.reconfigureItems(at: collectionView.indexPathsForVisibleItems) return } } + MVMCoreLoggingHandler.shared()?.handleDebugMessage("[\(Self.self)] Model is new. Rebuilding carousel.") accessibilityLabel = carouselModel.accessibilityText collectionView.layer.borderColor = UIColor.mvmCoolGray3.cgColor collectionView.layer.borderWidth = (carouselModel.border ?? false) ? 1 : 0 diff --git a/MVMCoreUI/Atomic/Organisms/Carousel/CarouselModel.swift b/MVMCoreUI/Atomic/Organisms/Carousel/CarouselModel.swift index 449d2abf..1ef5b98f 100644 --- a/MVMCoreUI/Atomic/Organisms/Carousel/CarouselModel.swift +++ b/MVMCoreUI/Atomic/Organisms/Carousel/CarouselModel.swift @@ -228,3 +228,11 @@ extension CarouselModel { } } + +extension CarouselModel: CustomDebugStringConvertible { + + public var debugDescription: String { + return "\(molecules.count) \(molecules.map { ($0 as? CarouselItemModel)?.molecule.moleculeName ?? "unknown" } )" + } + +} diff --git a/MVMCoreUI/Atomic/Protocols/ModelProtocols/ParentMoleculeModelProtocol.swift b/MVMCoreUI/Atomic/Protocols/ModelProtocols/ParentMoleculeModelProtocol.swift index c2f1e886..25dbcdc7 100644 --- a/MVMCoreUI/Atomic/Protocols/ModelProtocols/ParentMoleculeModelProtocol.swift +++ b/MVMCoreUI/Atomic/Protocols/ModelProtocols/ParentMoleculeModelProtocol.swift @@ -126,14 +126,16 @@ extension ParentModelProtocol { public typealias DeepCompareResult = (matched: Bool, myChild: ModelProtocol?, theirChild: ModelProtocol?) + public typealias ModelPair = (mine: ModelProtocol, theirs: ModelProtocol) + public func deepEquals(to model: any ModelProtocol) -> DeepCompareResult { guard let model = model as? ParentModelProtocol else { return (false, self, model) } - return deepCompare(model) { $0.isEqual(to: $1) } + return findFirst(in: model) { $0.isEqual(to: $1) } } - func deepCompare(_ anotherParent: ParentModelProtocol, with test: (ModelProtocol, ModelProtocol)->Bool) -> DeepCompareResult { + func findFirst(in anotherParent: ParentModelProtocol, where test: (ModelProtocol, ModelProtocol)->Bool) -> DeepCompareResult { - guard test(self, anotherParent) else { return (false, myChild: self, theirChild: self)} + guard test(self, anotherParent) else { return (false, myChild: self, theirChild: anotherParent)} let myChildren = children let theirChildren = anotherParent.children @@ -141,7 +143,7 @@ extension ParentModelProtocol { for index in myChildren.indices { if let myChild = myChildren[index] as? ParentModelProtocol { if let theirChild = theirChildren[index] as? ParentModelProtocol { - let result = myChild.deepCompare(theirChild, with: test) + let result = myChild.findFirst(in: theirChild, where: test) guard result.0 else { return result } } else { return (false, myChild: myChild, theirChild: theirChildren[index]) @@ -153,4 +155,26 @@ extension ParentModelProtocol { return (true, nil, nil) } + + func deepCompare(against anotherParent: ParentModelProtocol, where test: (ModelProtocol, ModelProtocol)->Bool) -> [ModelPair] { + + guard test(self, anotherParent) else { return [(self, anotherParent)]} + + let myChildren = children + let theirChildren = anotherParent.children + guard myChildren.count == theirChildren.count else { return [(self, anotherParent)] } + + var allDiffs = [ModelPair]() + for index in myChildren.indices { + if let myChild = myChildren[index] as? ParentModelProtocol, + let theirChild = theirChildren[index] as? ParentModelProtocol { + let childDiffs = myChild.deepCompare(against: theirChild, where: test) as [ModelPair] + allDiffs.append(contentsOf: childDiffs) + } else if !test(myChildren[index], theirChildren[index]) { + allDiffs.append((myChildren[index], theirChildren[index])) + } + } + + return allDiffs + } } diff --git a/MVMCoreUI/Atomic/Protocols/ModelProtocols/TemplateModelProtocol.swift b/MVMCoreUI/Atomic/Protocols/ModelProtocols/TemplateModelProtocol.swift index 5507eadb..2ee8a04a 100644 --- a/MVMCoreUI/Atomic/Protocols/ModelProtocols/TemplateModelProtocol.swift +++ b/MVMCoreUI/Atomic/Protocols/ModelProtocols/TemplateModelProtocol.swift @@ -10,6 +10,8 @@ public protocol TemplateModelProtocol: PageModelProtocol, ModelProtocol, MoleculeTreeTraversalProtocol, ParentModelProtocol { var template: String { get } var rootMolecules: [MoleculeModelProtocol] { get } + /// Page rendering ID. Unique betwen JSON parses. + var id: String { get } } public extension TemplateModelProtocol { diff --git a/MVMCoreUI/Atomic/Protocols/TemplateProtocol.swift b/MVMCoreUI/Atomic/Protocols/TemplateProtocol.swift index 41c8f56a..44584305 100644 --- a/MVMCoreUI/Atomic/Protocols/TemplateProtocol.swift +++ b/MVMCoreUI/Atomic/Protocols/TemplateProtocol.swift @@ -35,19 +35,26 @@ public extension TemplateProtocol { public extension TemplateProtocol where Self: PageBehaviorHandlerProtocol, Self: MVMCoreViewControllerProtocol { + func parseTemplate(loadObject: MVMCoreLoadObject) throws -> TemplateModelProtocol { + guard let pageJSON = loadObject.pageJSON else { + throw MVMCoreError.error(code: ErrorCode.parsingJSON.rawValue, messageToDisplay: "", messageToLog: "Load object is missing its page JSON!") + } + return try parseTemplate(pageJSON: pageJSON) + } + /// Helper function to do common parsing logic. - func parseTemplate(json: [AnyHashable: Any]?) throws { - guard let pageJSON = json else { return } + func parseTemplate(pageJSON: [AnyHashable: Any]) throws -> TemplateModelProtocol { let delegateObject = delegateObject?() as? MVMCoreUIDelegateObject let data = try JSONSerialization.data(withJSONObject: pageJSON) let decoder = JSONDecoder.create(with: delegateObject) - templateModel = try decodeTemplate(using: decoder, from: data) + let templateModel = try decodeTemplate(using: decoder, from: data) - // Add additional required behaviors if applicable. - guard var pageBehaviorsModel = templateModel as? TemplateModelProtocol & PageBehaviorContainerModelProtocol else { return } + // Add additional required behavior models to the template if applicable. + guard var templateBehaviorsModel = templateModel as? TemplateModelProtocol & PageBehaviorContainerModelProtocol else { + return templateModel + } + templateBehaviorsModel.traverseAndAddRequiredBehaviors() - pageBehaviorsModel.traverseAndAddRequiredBehaviors() - var behaviorHandler = self - behaviorHandler.applyBehaviors(pageBehaviorModel: pageBehaviorsModel, delegateObject: delegateObject) + return templateModel } } diff --git a/MVMCoreUI/Atomic/Templates/BaseTemplateModel.swift b/MVMCoreUI/Atomic/Templates/BaseTemplateModel.swift index 8700fed1..78c7d61b 100644 --- a/MVMCoreUI/Atomic/Templates/BaseTemplateModel.swift +++ b/MVMCoreUI/Atomic/Templates/BaseTemplateModel.swift @@ -10,11 +10,14 @@ import Foundation @objcMembers open class BaseTemplateModel: MVMControllerModelProtocol, TabPageModelProtocol { + //-------------------------------------------------- // MARK: - Properties //-------------------------------------------------- open class var identifier: String { "" } + public var id: String = UUID().uuidString + public var pageType: String public var template: String { // Although this is done in the extension, it is needed for the encoding. diff --git a/MVMCoreUI/Atomic/Templates/CollectionTemplate.swift b/MVMCoreUI/Atomic/Templates/CollectionTemplate.swift index 100b1b17..e9e8b46f 100644 --- a/MVMCoreUI/Atomic/Templates/CollectionTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/CollectionTemplate.swift @@ -21,9 +21,8 @@ //-------------------------------------------------- // MARK: - Computed Properties //-------------------------------------------------- - open override func parsePageJSON() throws { - try parseTemplate(json: loadObject?.pageJSON) - try super.parsePageJSON() + open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol { + return try parseTemplate(loadObject: loadObject) } open override var loadObject: MVMCoreLoadObject? { @@ -80,10 +79,10 @@ } - open override func handleNewData() { + open override func handleNewData(_ pageModel: PageModelProtocol? = nil) { setup() registerCells() - super.handleNewData() + super.handleNewData(pageModel) } open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { diff --git a/MVMCoreUI/Atomic/Templates/ModalMoleculeListTemplate.swift b/MVMCoreUI/Atomic/Templates/ModalMoleculeListTemplate.swift index 8f8ca005..16afd5bd 100644 --- a/MVMCoreUI/Atomic/Templates/ModalMoleculeListTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/ModalMoleculeListTemplate.swift @@ -25,8 +25,8 @@ open class ModalMoleculeListTemplate: MoleculeListTemplate { try decoder.decode(ModalListPageTemplateModel.self, from: data) } - override open func handleNewData() { - super.handleNewData() + override open func handleNewData(_ pageModel: PageModelProtocol? = nil) { + super.handleNewData(pageModel) closeButton = MVMCoreUICommonViewsUtility.addCloseButton(to: view, action: { [weak self] _ in guard let self = self else { return } diff --git a/MVMCoreUI/Atomic/Templates/ModalMoleculeStackTemplate.swift b/MVMCoreUI/Atomic/Templates/ModalMoleculeStackTemplate.swift index 97aced2a..f3264b1b 100644 --- a/MVMCoreUI/Atomic/Templates/ModalMoleculeStackTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/ModalMoleculeStackTemplate.swift @@ -23,8 +23,8 @@ open class ModalMoleculeStackTemplate: MoleculeStackTemplate { // MARK: - Lifecycle //-------------------------------------------------- - override open func handleNewData() { - super.handleNewData() + override open func handleNewData(_ pageModel: PageModelProtocol? = nil) { + super.handleNewData(pageModel) _ = MVMCoreUICommonViewsUtility.addCloseButton(to: view, action: { [weak self] _ in guard let self = self else { return } let closeAction = (self.templateModel as? ModalStackPageTemplateModel)?.closeAction ?? diff --git a/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift b/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift index d62fd149..ad908568 100644 --- a/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift @@ -46,9 +46,8 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol // MARK: - Methods //-------------------------------------------------- - open override func parsePageJSON() throws { - try parseTemplate(json: loadObject?.pageJSON) - try super.parsePageJSON() + open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol { + return try parseTemplate(loadObject: loadObject) } // For subclassing the model. @@ -86,15 +85,16 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol return view } - open override func handleNewData() { + open override func handleNewData(_ pageModel: PageModelProtocol? = nil) { + super.handleNewData(pageModel) setup() registerWithTable() - super.handleNewData() // Currently stuck as MoleculeListProtocol being called from AddRemoveMoleculesBehaviorModel. } open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { topViewOutsideOfScrollArea = templateModel?.anchorHeader ?? false bottomViewOutsideOfScrollArea = templateModel?.anchorFooter ?? false + super.updateUI(for: molecules) guard let molecules else { return } diff --git a/MVMCoreUI/Atomic/Templates/MoleculeStackTemplate.swift b/MVMCoreUI/Atomic/Templates/MoleculeStackTemplate.swift index 8ef02f39..8ce8cd67 100644 --- a/MVMCoreUI/Atomic/Templates/MoleculeStackTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/MoleculeStackTemplate.swift @@ -20,10 +20,10 @@ open class MoleculeStackTemplate: ThreeLayerViewController, TemplateProtocol { // MARK: - Lifecycle //-------------------------------------------------- - open override func handleNewData() { + open override func handleNewData(_ pageModel: PageModelProtocol? = nil) { topViewOutsideOfScroll = templateModel?.anchorHeader ?? false bottomViewOutsideOfScroll = templateModel?.anchorFooter ?? false - super.handleNewData() + super.handleNewData(pageModel) } // For subclassing the model. @@ -31,9 +31,8 @@ open class MoleculeStackTemplate: ThreeLayerViewController, TemplateProtocol { return try decoder.decode(StackPageTemplateModel.self, from: data) } - open override func parsePageJSON() throws { - try parseTemplate(json: loadObject?.pageJSON) - try super.parsePageJSON() + open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol { + return try parseTemplate(loadObject: loadObject) } open override var loadObject: MVMCoreLoadObject? { diff --git a/MVMCoreUI/Atomic/Templates/ThreeLayerTemplate.swift b/MVMCoreUI/Atomic/Templates/ThreeLayerTemplate.swift index b0e36239..b4af2ed4 100644 --- a/MVMCoreUI/Atomic/Templates/ThreeLayerTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/ThreeLayerTemplate.swift @@ -14,9 +14,8 @@ import UIKit // MARK: - Lifecycle //-------------------------------------------------- - open override func parsePageJSON() throws { - try parseTemplate(json: loadObject?.pageJSON) - try super.parsePageJSON() + open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol { + return try parseTemplate(loadObject: loadObject) } open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { diff --git a/MVMCoreUI/BaseControllers/ViewController.swift b/MVMCoreUI/BaseControllers/ViewController.swift index fe13c3eb..0486f6b4 100644 --- a/MVMCoreUI/BaseControllers/ViewController.swift +++ b/MVMCoreUI/BaseControllers/ViewController.swift @@ -40,6 +40,7 @@ import MVMCore public var needsUpdateUI = false private var observingForResponses: NSObjectProtocol? private var initialLoadFinished = false + private var isFirstRender = true public var previousScreenSize = CGSize.zero public var selectedField: UIView? @@ -83,14 +84,16 @@ import MVMCore open func modulesToListenFor() -> [String]? { let requestModules = loadObject?.requestParameters?.allModules() ?? [] - let behaviorModules = behaviors?.flatMap { $0.modulesToListenFor() } ?? [] + let behaviorModules = behaviors?.flatMap { $0.modulesToListenFor } ?? [] return requestModules + behaviorModules } @objc open func responseJSONUpdated(notification: Notification) { // Checks for a page we are listening for. - var newData = false + 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), let pageType = page.optionalStringForKey(KeyPageType), @@ -99,8 +102,21 @@ import MVMCore return true }) { - newData = true - loadObject?.pageJSON = pagesLoaded.optionalDictionaryForKey(pageType) + hasDataUpdate = true + loadObject.pageJSON = pagesLoaded.optionalDictionaryForKey(pageType) + + // TODO: Parse parsePageJSON modifies the page model on a different thread than + // the UI update which could cause discrepancies. Parse should return the resulting + // object and assignment should be synchronized on handleNewData(model: ). + + // 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) + } + } } // Checks for modules we are listening for. @@ -108,7 +124,7 @@ import MVMCore let modulesListened = modulesToListenFor() { for moduleName in modulesListened { if let module = modulesLoaded.optionalDictionaryForKey(moduleName) { - newData = true + hasDataUpdate = true var currentModules = loadObject?.modulesJSON ?? [:] currentModules.updateValue(module, forKey: moduleName) loadObject?.modulesJSON = currentModules @@ -116,21 +132,11 @@ import MVMCore } } - guard newData else { return } + guard hasDataUpdate else { return } - do { - // TODO: Parse parsePageJSON modifies the page model on a different thread than - // the UI update which could cause discrepancies. Parse should return the resulting - // object and assignment should be synchronized on handleNewData(model: ). - try parsePageJSON() - MVMCoreDispatchUtility.performBlock(onMainThread: { - self.handleNewData() - }) - } catch { - if let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: "updateJSON for pageType: \(String(describing: pageType))") { - MVMCoreLoggingHandler.shared()?.addError(toLog: coreError) - } - } + MVMCoreDispatchUtility.performBlock(onMainThread: { + self.handleNewData(pageModel) + }) } open func shouldFinishProcessingLoad(_ loadObject: MVMCoreLoadObject, error: AutoreleasingUnsafeMutablePointer) -> Bool { @@ -142,7 +148,12 @@ import MVMCore // Parse the model for the page. do { - try parsePageJSON() + let template = try parsePageJSON(loadObject: loadObject) + pageModel = template // TODO: Eventually this page parsing should be done outside of this class and then set by the caller. For now, double duty. + isFirstRender = true + if let backgroundRequest = loadObject.requestParameters?.backgroundRequest, !backgroundRequest, let pageType, let identifier = loadObject.identifier { + MVMCoreLoggingHandler.shared()?.logCoreEvent(.pageProcessingComplete(pageType: pageType, requestUUID: identifier, webUrl: nil)) + } } catch let parsingError { // Log all parsing errors and fail load. if let errorObject = MVMCoreLoadHandler.sharedGlobal()?.error(for: loadObject, causedBy: parsingError) { @@ -182,10 +193,8 @@ import MVMCore return "Error parsing template. \((parsingError as NSError).localizedFailureReason ?? parsingError.localizedDescription)" } - open func parsePageJSON() throws { - if let backgroundRequest = loadObject?.requestParameters?.backgroundRequest, !backgroundRequest, let pageType, let identifier = loadObject?.identifier { - MVMCoreLoggingHandler.shared()?.logCoreEvent(.pageProcessingComplete(pageType: pageType, requestUUID: identifier, webUrl: nil)) - } + open func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol { + throw MVMCoreError.error(code: ErrorCode.parsingJSON.rawValue, messageToDisplay: "Template needs to define its model!", messageToLog: "Template needs to define its model!") } open class func verifyRequiredModulesLoaded(for loadObject: MVMCoreLoadObject?, error: AutoreleasingUnsafeMutablePointer) -> Bool { @@ -222,26 +231,77 @@ import MVMCore /// Processes any new data. Called after the page is loaded the first time and on response updates for this page, Triggers a render refresh. @MainActor - open func handleNewData() { - if model?.navigationBar == nil { + open func handleNewData(_ pageModel: PageModelProtocol? = nil) { + + guard var newPageModel = pageModel ?? self.pageModel else { return } + let originalModel = self.isFirstRender ? self.pageModel as? MVMControllerModelProtocol : nil + + if originalModel != nil, let behaviorContainer = newPageModel as? PageBehaviorContainerModelProtocol { + var behaviorHandler = self + behaviorHandler.applyBehaviors(pageBehaviorModel: behaviorContainer) + } + + if newPageModel.navigationBar == nil { let navigationItem = createDefaultLegacyNavigationModel() - model?.navigationBar = navigationItem + newPageModel.navigationBar = navigationItem } - executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in - behavior.onPageNew(rootMolecules: getRootMolecules(), delegateObjectIVar) + self.pageModel = newPageModel + + var behaviorUpdatedModels = [MoleculeModelProtocol]() + if var newTemplateModel = newPageModel as? TemplateModelProtocol { + executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in + if let updatedMolecules = behavior.onPageNew(rootMolecules: newTemplateModel.rootMolecules, delegateObjectIVar) { + updatedMolecules.forEach { molecule in + if let replaced = try? newTemplateModel.replaceChildMolecule(with: molecule) { + if !replaced.isEqual(to: molecule) { // Only recognize the molecules that actually changed. + debugLog("Behavior updated \(molecule) in template model.") + behaviorUpdatedModels.append(molecule) // Need to specifically trace molecule updates here as replacements are modifying the original tree. (We don't have a deep copy.) + } + } + } + } + } } - if formValidator == nil { - let rules = model?.formRules + if formValidator == nil { // TODO: Can't change form rules? + let rules = (newPageModel as? MVMControllerModelProtocol)?.formRules formValidator = FormValidator(rules) } - updateUI() + self.pageModel = newPageModel - // Notify the manager of new data. - // Warning: Some flows cause table reloads. Until the UI update is decoupled, should be after the updateUI. - manager?.newDataReceived?(in: self) + /// Run through the differences between separate page model trees. + var pageUpdatedModels = [MoleculeModelProtocol]() + if let originalModel, // We had a prior. + let newPageModel = newPageModel as? TemplateModelProtocol, + originalModel.id != newPageModel.id { + let diffs = newPageModel.deepCompare(against: originalModel) { new, old in + !new.isEqual(to: old) + } + debugLog("Page molecule updates\n\(diffs.map {"\($0.mine) vs. \($0.theirs)"}.joined(separator: "\n"))") + pageUpdatedModels = diffs.compactMap { $0.mine as? MoleculeModelProtocol } + } + + let allUpdatedMolecules = isFirstRender ? [] : behaviorUpdatedModels + pageUpdatedModels + + isFirstRender = false + + // Dispatch to decouple execution. First massage data through template classes, then render. + Task { @MainActor in + + if allUpdatedMolecules.isEmpty { + debugLog("Performing full page render...") + updateUI() + } else { + debugLog("Updating \(allUpdatedMolecules) molecules...") + updateUI(for: allUpdatedMolecules) + } + + // Notify the manager of new data. + // Warning: Some flows cause table reloads. Until the UI update is decoupled, should be after the updateUI. + manager?.newDataReceived?(in: self) + } } /// Applies the latest model to the UI. @@ -312,7 +372,7 @@ import MVMCore super.viewDidLoad() // Do any additional setup after loading the view. - MVMCoreLoggingHandler.logDebugMessage(withDelegate: "View Controller Loaded : \(self)") + debugLog("View Controller Loaded") // We use our own margins. viewRespectsSystemMinimumLayoutMargins = false @@ -326,7 +386,7 @@ import MVMCore initialLoad() } - handleNewData() + handleNewData(pageModel) // Set outside shouldFinishProcessingLoad. } open override func viewDidLayoutSubviews() { @@ -395,7 +455,7 @@ import MVMCore deinit { stopObservingForResponseJSONUpdates() - MVMCoreLoggingHandler.logDebugMessage(withDelegate: "View Controller Deallocated : \(self)") + debugLog("Deallocated") } open override var supportedInterfaceOrientations: UIInterfaceOrientationMask { @@ -514,22 +574,22 @@ import MVMCore open func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) { } public func replaceMoleculeData(_ moleculeModels: [MoleculeModelProtocol], completionHandler: (([MoleculeModelProtocol])->Void)? = nil) { - pageUpdateQueue.addOperation { + pageUpdateQueue.addOperation { [self] in let replacedModels:[(MoleculeModelProtocol, MoleculeModelProtocol)] = moleculeModels.compactMap { model in - guard let replacedMolecule = self.attemptToReplace(with: model) else { + guard let replacedMolecule = attemptToReplace(with: model) else { return nil } return (model, replacedMolecule) } let uiUpdatedModels: [MoleculeModelProtocol] = replacedModels.compactMap { new, existing in guard !new.isEqual(to: existing) else { - MVMCoreLoggingHandler.shared()?.handleDebugMessage("UI for molecules: \(new) is the same. Skip UI update.") + debugLog("UI for molecules: \(new) is the same. Skip UI update.") return nil } return new } if uiUpdatedModels.count > 0 { - MVMCoreLoggingHandler.shared()?.handleDebugMessage("Updating UI for molecules: \(uiUpdatedModels)") + debugLog("Updating UI for molecules: \(uiUpdatedModels)") DispatchQueue.main.sync { self.updateUI(for: uiUpdatedModels) } @@ -668,3 +728,15 @@ import MVMCore } } } + +extension ViewController: CoreLogging { + + public var loggingPrefix: String { + "\(self) \(pageType ?? ""): " + } + + public static var loggingCategory: String? { + return "Rendering" + } + +} diff --git a/MVMCoreUI/Behaviors/PollingBehaviorModel.swift b/MVMCoreUI/Behaviors/PollingBehaviorModel.swift index 0d40f4cd..719e22ea 100644 --- a/MVMCoreUI/Behaviors/PollingBehaviorModel.swift +++ b/MVMCoreUI/Behaviors/PollingBehaviorModel.swift @@ -68,7 +68,7 @@ public class PollingBehavior: NSObject, PageVisibilityBehavior, PageMoleculeTran model.refreshInterval + lastRefresh.timeIntervalSinceNow // timeIntervalSinceNow in negative since earlier recording (--) } - var firstTimeLoad = true + var firstTimeLoad = true // TODO: Model replacement is probably going to impact this. Need to transfer first load state. var refreshOnShown: Bool { if model.refreshOnFirstLoad && firstTimeLoad { @@ -84,11 +84,12 @@ public class PollingBehavior: NSObject, PageVisibilityBehavior, PageMoleculeTran Self.debugLog("Initializing for \(model)") } - public func onPageNew(rootMolecules: [any MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { + public func onPageNew(rootMolecules: [any MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) -> [MoleculeModelProtocol]? { if let behaviorVC = delegateObject?.moleculeDelegate as? ViewController, MVMCoreUIUtility.getCurrentVisibleController() == behaviorVC { // If behavior is initialized after the page is shown, we need to start the timer. Don't immediately start an action. That is triggered by onPageShown if its a fresh view. resumePollingTimer(withRemainingTime: remainingTimeToRefresh, refreshAction: model.refreshAction, interval: model.refreshInterval) } + return nil } public func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) { diff --git a/MVMCoreUI/Behaviors/Protocols/PageBehaviorProtocol.swift b/MVMCoreUI/Behaviors/Protocols/PageBehaviorProtocol.swift index ae09e0ce..6cc30e95 100644 --- a/MVMCoreUI/Behaviors/Protocols/PageBehaviorProtocol.swift +++ b/MVMCoreUI/Behaviors/Protocols/PageBehaviorProtocol.swift @@ -14,7 +14,7 @@ public protocol PageBehaviorProtocol: ModelHandlerProtocol { /// Should the behavior persist regardless of page behavior model updates. var transcendsPageUpdates: Bool { get } - func modulesToListenFor() -> [String] + var modulesToListenFor: [String] { get } /// Initializes the behavior with the model init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) @@ -22,7 +22,7 @@ public protocol PageBehaviorProtocol: ModelHandlerProtocol { public extension PageBehaviorProtocol { var transcendsPageUpdates: Bool { return false } - func modulesToListenFor() -> [String] { return [] } + var modulesToListenFor: [String] { return [] } } /** @@ -30,7 +30,7 @@ public extension PageBehaviorProtocol { */ public protocol PageMoleculeTransformationBehavior: PageBehaviorProtocol { - func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) + func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) -> [MoleculeModelProtocol]? func willSetupMolecule(with model: MoleculeModelProtocol, updating view: MoleculeViewProtocol?) func didSetupMolecule(view: MoleculeViewProtocol, withModel: MoleculeModelProtocol) func willSetupNavigationBar(with model: NavigationItemModelProtocol, updating view: UINavigationBar) @@ -41,7 +41,7 @@ public protocol PageMoleculeTransformationBehavior: PageBehaviorProtocol { public extension PageMoleculeTransformationBehavior { // All optional. - func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) {} + func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) -> [MoleculeModelProtocol]? { return nil } func willSetupMolecule(with model: MoleculeModelProtocol, updating view: MoleculeViewProtocol?) {} func didSetupMolecule(view: MoleculeViewProtocol, withModel: MoleculeModelProtocol) {} func willSetupNavigationBar(with model: NavigationItemModelProtocol, updating view: UINavigationBar) {} diff --git a/MVMCoreUI/Behaviors/ReplaceableMoleculeBehaviorModel.swift b/MVMCoreUI/Behaviors/ReplaceableMoleculeBehaviorModel.swift index 08f9c2f9..4febdcc1 100644 --- a/MVMCoreUI/Behaviors/ReplaceableMoleculeBehaviorModel.swift +++ b/MVMCoreUI/Behaviors/ReplaceableMoleculeBehaviorModel.swift @@ -18,7 +18,7 @@ public class ReplaceableMoleculeBehaviorModel: PageBehaviorModelProtocol { public class ReplaceableMoleculeBehavior: PageMoleculeTransformationBehavior, CoreLogging { public var loggingPrefix: String { - "\(self) \(ObjectIdentifier(self))\n\(moleculeIds)\n" + "\(self) \(ObjectIdentifier(self).hashValue) \(moleculeIds.prefix(3)) \(moleculeIds.count > 3 ? "+ \(moleculeIds.count - 3) more" : ""):\n" } public static var loggingCategory: String? { @@ -26,37 +26,22 @@ public class ReplaceableMoleculeBehavior: PageMoleculeTransformationBehavior, Co } var moleculeIds: [String] - var modulesToListenFor: [String] + public var modulesToListenFor: [String] private var observingForResponses: NSObjectProtocol? private var delegateObject: MVMCoreUIDelegateObject? public required init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) { moleculeIds = (model as! ReplaceableMoleculeBehaviorModel).moleculeIds - let shouldListenForListUpdates = delegateObject?.moleculeListDelegate != nil - if shouldListenForListUpdates { - modulesToListenFor = [] - listenForModuleUpdates() - } else { - modulesToListenFor = moleculeIds - stopListeningForModuleUpdates() - } + modulesToListenFor = moleculeIds self.delegateObject = delegateObject guard let pageType = delegateObject?.moleculeDelegate?.getTemplateModel()?.pageType else { return } MVMCoreViewControllerMappingObject.shared()?.addOptionalModules(toMapping: moleculeIds, forPageType: pageType) Self.debugLog("Initializing for \((model as! ReplaceableMoleculeBehaviorModel).moleculeIds)") } - public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { - debugLog("onPageNew") + public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) -> [MoleculeModelProtocol]? { self.delegateObject = delegateObject - let shouldListenForListUpdates = delegateObject?.moleculeListDelegate != nil - if shouldListenForListUpdates { - modulesToListenFor = [] - listenForModuleUpdates() - } else { - modulesToListenFor = moleculeIds - stopListeningForModuleUpdates() - } + modulesToListenFor = moleculeIds let moleculeModels = moleculeIds.compactMap { moleculeId in do { @@ -70,65 +55,61 @@ public class ReplaceableMoleculeBehavior: PageMoleculeTransformationBehavior, Co return nil } } - if moleculeModels.count > 0 { - // TODO: Getting dropped into the page update queue. Can we get this replaced without an async dispatch to avoid an animation? - delegateObject?.moleculeDelegate?.replaceMoleculeData(moleculeModels, completionHandler: nil) - } + + return findAndReplace(moleculeModels, in: rootMolecules) } - private func listenForModuleUpdates() { - guard observingForResponses == nil else { return } - let pageUpdateQueue = OperationQueue() - pageUpdateQueue.maxConcurrentOperationCount = 1 - pageUpdateQueue.qualityOfService = .userInteractive - observingForResponses = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil, queue: pageUpdateQueue) { [weak self] notification in - self?.responseJSONUpdated(notification: notification) - } - } - - private func stopListeningForModuleUpdates() { - guard let observingForResponses = observingForResponses else { return } - NotificationCenter.default.removeObserver(observingForResponses) - self.observingForResponses = nil - } - - @objc func responseJSONUpdated(notification: Notification) { - guard let modulesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyModuleMap) else { return } - let modules: [MoleculeModelProtocol] = moleculeIds.compactMap { moleculeId in - guard let json = modulesLoaded.optionalDictionaryForKey(moleculeId) else { return nil } - do { - return try convertToModel(moduleJSON: json) - } catch { - let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: String(describing: type(of: self)))! - if let error = error as? HumanReadableDecodingErrorProtocol { - coreError.messageToLog = "Error decoding replacement \"\(moleculeId)\": \(error.readableDescription)" + fileprivate func findAndReplace(_ moleculeModels: [any MoleculeModelProtocol], in rootMolecules: [any MoleculeModelProtocol]) -> [any MoleculeModelProtocol]? { + debugLog("onPageNew replacing \(moleculeModels.map { $0.id })") + var hasReplacement = false + let updatedRootMolecules = rootMolecules.map { rootMolecule in + + // Top level check to return a new root molecule. + if let updatedMolecule = moleculeModels.first(where: { rootMolecule.id == $0.id }) { + guard !updatedMolecule.isEqual(to: rootMolecule) else { + debugLog("onPageNew molecule \(updatedMolecule) is the same as \(rootMolecule). skipping...") + return rootMolecule } - MVMCoreLoggingHandler.shared()?.addError(toLog: coreError) - return nil + debugLog("onPageNew replacing \(rootMolecule) with \(updatedMolecule)") + logUpdated(molecule: updatedMolecule) + hasReplacement = true + return updatedMolecule } + + // Deep child check to replace a root's child. + guard var parentMolecule = rootMolecule as? ParentMoleculeModelProtocol else { return rootMolecule } + + moleculeModels.forEach { newMolecule in + do { + if let replacedMolecule = try parentMolecule.replaceChildMolecule(with: newMolecule) { + guard !replacedMolecule.isEqual(to: newMolecule) else { + // Note: Slight risk here of replacing the something in the original tree and misreporting that is it not replaced based on equality. + debugLog("onPageNew molecule \(newMolecule) is the same as \(replacedMolecule). skipping...") + return + } + debugLog("onPageNew replacing \(replacedMolecule) with \(newMolecule)") + logUpdated(molecule: newMolecule) + hasReplacement = true + } + } catch { + + } + } + return parentMolecule } - guard modules.count > 0 else { return } - #if LOGGING - let requestParams = (notification.userInfo?["MVMCoreLoadObject"] as? MVMCoreLoadObject)?.requestParameters - debugLog("Replacing \(modules.map { $0.id }) from \(requestParams?.url?.absoluteString ?? "unknown"), e2eId: \(requestParams?.identifier ?? "unknown")") - #endif - delegateObject?.moleculeDelegate?.replaceMoleculeData(modules) { replacedModels in - let modules = replacedModels.compactMap { modulesLoaded.dictionaryForKey($0.id) } - guard let viewController = self.delegateObject?.moleculeDelegate as? MVMCoreViewControllerProtocol else { return } - modules.forEach { MVMCoreUILoggingHandler.shared()?.defaultLogPageUpdate(forController: viewController, from: $0) } - } + return hasReplacement ? updatedRootMolecules : nil } - private func convertToModel(moduleJSON: [String: Any]) throws -> MoleculeModelProtocol { - guard let moleculeName = moduleJSON.optionalStringForKey(KeyMoleculeName), - let modelType = ModelRegistry.getType(for: moleculeName, with: MoleculeModelProtocol.self) else { - throw ModelRegistry.Error.decoderErrorModelNotMapped(identifer: moduleJSON.optionalStringForKey(KeyMoleculeName)) + private func logUpdated(molecule: MoleculeModelProtocol) { + guard let module: [AnyHashable: Any] = delegateObject?.moleculeDelegate?.getModuleWithName(molecule.id), + let viewController = delegateObject?.moleculeDelegate as? MVMCoreViewControllerProtocol else { + debugLog("Missing the originating module \(molecule.id) creating this molecule!") + return } - return try modelType.decode(jsonDict: moduleJSON as [String : Any]) as! MoleculeModelProtocol + MVMCoreUILoggingHandler.shared()?.defaultLogPageUpdate(forController: viewController, from: module) } deinit { debugLog("deinit") - stopListeningForModuleUpdates() } }