From 50646851bae2f986c91ba8f6146e71de229e8618 Mon Sep 17 00:00:00 2001 From: "Hedden, Kyle Matthew" Date: Thu, 26 Oct 2023 12:28:56 -0400 Subject: [PATCH] move model replacement to the viewcontroller for model state synchronization & stability. shift replacement molecule handling to the behavior for targeted updates. --- .../Protocols/MoleculeDelegateProtocol.swift | 2 + .../MoleculeTreeTraversalProtocol.swift | 20 +++++ .../Atomic/Templates/CollectionTemplate.swift | 4 +- .../Templates/ModalSectionListTemplate.swift | 4 +- .../Templates/MoleculeListTemplate.swift | 52 ++++++------- .../ThreeLayerFillMiddleTemplate.swift | 4 +- .../Atomic/Templates/ThreeLayerTemplate.swift | 4 +- .../ScrollingViewController.swift | 4 +- .../ThreeLayerCollectionViewController.swift | 4 +- .../ThreeLayerTableViewController.swift | 7 +- .../ThreeLayerViewController.swift | 4 +- .../BaseControllers/ViewController.swift | 50 +++++++++++-- .../ReplaceableMoleculeBehaviorModel.swift | 74 ++++++++++++++++--- 13 files changed, 168 insertions(+), 65 deletions(-) diff --git a/MVMCoreUI/Atomic/Protocols/MoleculeDelegateProtocol.swift b/MVMCoreUI/Atomic/Protocols/MoleculeDelegateProtocol.swift index e78c0b1b..e7a4bdef 100644 --- a/MVMCoreUI/Atomic/Protocols/MoleculeDelegateProtocol.swift +++ b/MVMCoreUI/Atomic/Protocols/MoleculeDelegateProtocol.swift @@ -21,6 +21,8 @@ public protocol MoleculeDelegateProtocol: AnyObject { /// Notifies the delegate that the molecule layout update. Should be called when the layout may change due to an async method. Mainly used for list or collections. func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) //optional + + func replaceMoleculeData(_ moleculeModels: [MoleculeModelProtocol]) } extension MoleculeDelegateProtocol { diff --git a/MVMCoreUI/Atomic/Protocols/MoleculeTreeTraversalProtocol.swift b/MVMCoreUI/Atomic/Protocols/MoleculeTreeTraversalProtocol.swift index f595af9b..b030d92a 100644 --- a/MVMCoreUI/Atomic/Protocols/MoleculeTreeTraversalProtocol.swift +++ b/MVMCoreUI/Atomic/Protocols/MoleculeTreeTraversalProtocol.swift @@ -50,4 +50,24 @@ public extension MoleculeTreeTraversalProtocol { return accumulator } } + + func filterMoleculeTree(options: TreeTraversalOptions = .parentFirst, by condition: (MoleculeModelProtocol)->Bool) -> [MoleculeModelProtocol] { + return reduceDepthFirstTraverse(options: options, depth: 0, initialResult: []) { (accumulator, molecule, depth) in + if condition(molecule) { + return accumulator + [molecule] + } + return accumulator + } + } + + func findFirstMolecule(options: TreeTraversalOptions = .parentFirst, by condition: (MoleculeModelProtocol)->Bool) -> MoleculeModelProtocol? { + var foundMolecule: MoleculeModelProtocol? + depthFirstTraverse(options: options, depth: 0) { depth, molecule, isDone in + isDone = condition(molecule) + if isDone { + foundMolecule = molecule + } + } + return foundMolecule + } } diff --git a/MVMCoreUI/Atomic/Templates/CollectionTemplate.swift b/MVMCoreUI/Atomic/Templates/CollectionTemplate.swift index 896aa5bf..1f8cc49e 100644 --- a/MVMCoreUI/Atomic/Templates/CollectionTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/CollectionTemplate.swift @@ -86,10 +86,10 @@ super.handleNewData() } - open override func updateUI() { + open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { topViewOutsideOfScrollArea = templateModel?.anchorHeader ?? false bottomViewOutsideOfScrollArea = templateModel?.anchorFooter ?? false - super.updateUI() + super.updateUI(for: molecules) } //-------------------------------------------------- diff --git a/MVMCoreUI/Atomic/Templates/ModalSectionListTemplate.swift b/MVMCoreUI/Atomic/Templates/ModalSectionListTemplate.swift index 071b78c2..a328dac4 100644 --- a/MVMCoreUI/Atomic/Templates/ModalSectionListTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/ModalSectionListTemplate.swift @@ -21,8 +21,8 @@ open class ModalSectionListTemplate: SectionListTemplate { // MARK: - Lifecycle //-------------------------------------------------- - override open func updateUI() { - super.updateUI() + override open func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { + super.updateUI(for: molecules) _ = MVMCoreUICommonViewsUtility.addCloseButton(to: view, action: { [weak self] _ in guard let self = self else { return } let closeAction = (self.templateModel as? ModalSectionListTemplateModel)?.closeAction ?? diff --git a/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift b/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift index 406f72a9..1af1a85a 100644 --- a/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift @@ -87,10 +87,17 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol super.handleNewData() // Currently stuck as MoleculeListProtocol being called from AddRemoveMoleculesBehaviorModel. } - open override func updateUI() { + open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { topViewOutsideOfScrollArea = templateModel?.anchorHeader ?? false bottomViewOutsideOfScrollArea = templateModel?.anchorFooter ?? false - super.updateUI() + super.updateUI(for: molecules) + + molecules?.forEach({ molecule in + if let index = moleculesInfo?.firstIndex(where: { $0.molecule.id == molecule.id }) { + moleculesInfo?[index].molecule = molecule + } + newData(for: molecule) + }) } open override func viewDidAppear(_ animated: Bool) { @@ -196,30 +203,20 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol open func newData(for molecule: MoleculeModelProtocol) { //TODO: expand for header, navigation, etc - guard let index = moleculesInfo?.firstIndex(where: { (moleculeInfo) -> Bool in - if equal(moleculeA: molecule, moleculeB: moleculeInfo.molecule) { - return true - } else if let parent = moleculeInfo.molecule as? ParentMoleculeModelProtocol { - // Get all molecules of the same type for faster check. - let molecules: [MoleculeModelProtocol] = parent.reduceDepthFirstTraverse(options: .childFirst, depth: 0, initialResult: []) { (accumulator, currentMolecule, depth) in - if currentMolecule.moleculeName == molecule.moleculeName { - return accumulator + [currentMolecule] - } - return accumulator - } - for moleculeB in molecules { - if equal(moleculeA: molecule, moleculeB: moleculeB) { - return true - } - } - } - return false - }) else { return } + guard let moleculesInfo = moleculesInfo else { return } + + let indicies = moleculesInfo.indices.filter({ index -> Bool in + return moleculesInfo[index].molecule.findFirstMolecule(by: { + $0.moleculeName == molecule.moleculeName && equal(moleculeA: molecule, moleculeB: $0) + }) != nil + }) // Refresh the cell. (reload loses cell selection) let selectedIndex = tableView.indexPathForSelectedRow - let indexPath = IndexPath(row: index, section: 0) - tableView.reloadRows(at: [indexPath], with: .automatic) + let indexPaths = indicies.map { + return IndexPath(row: $0, section: 0) + } + tableView.reloadRows(at: indexPaths, with: .automatic) if let selectedIndex = selectedIndex { tableView.selectRow(at: selectedIndex, animated: false, scrollPosition: .none) } @@ -292,14 +289,7 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol /// Checks if the two molecules are equal private func equal(moleculeA: MoleculeModelProtocol, moleculeB: MoleculeModelProtocol) -> Bool { - // TODO: move this to a better approach, maybe a UUID for each model. - // Do instance check - if let classMoleculeA = moleculeA as? NSObjectProtocol, - let classMoleculeB = moleculeB as? NSObjectProtocol { - return classMoleculeA === classMoleculeB - } - // Do json check - return moleculeA.toJSON() == moleculeB.toJSON() + return moleculeA.id == moleculeB.id } } diff --git a/MVMCoreUI/Atomic/Templates/ThreeLayerFillMiddleTemplate.swift b/MVMCoreUI/Atomic/Templates/ThreeLayerFillMiddleTemplate.swift index bb6c5bdb..993cd9f0 100644 --- a/MVMCoreUI/Atomic/Templates/ThreeLayerFillMiddleTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/ThreeLayerFillMiddleTemplate.swift @@ -26,8 +26,8 @@ } } - open override func updateUI() { - super.updateUI() + open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { + super.updateUI(for: molecules) heightConstraint?.isActive = true } } diff --git a/MVMCoreUI/Atomic/Templates/ThreeLayerTemplate.swift b/MVMCoreUI/Atomic/Templates/ThreeLayerTemplate.swift index d0203e70..b0e36239 100644 --- a/MVMCoreUI/Atomic/Templates/ThreeLayerTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/ThreeLayerTemplate.swift @@ -19,10 +19,10 @@ import UIKit try super.parsePageJSON() } - open override func updateUI() { + open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { topViewOutsideOfScroll = templateModel?.anchorHeader ?? false bottomViewOutsideOfScroll = templateModel?.anchorFooter ?? false - super.updateUI() + super.updateUI(for: molecules) } open override func viewForTop() -> UIView? { diff --git a/MVMCoreUI/BaseControllers/ScrollingViewController.swift b/MVMCoreUI/BaseControllers/ScrollingViewController.swift index e7698d49..49bda46b 100644 --- a/MVMCoreUI/BaseControllers/ScrollingViewController.swift +++ b/MVMCoreUI/BaseControllers/ScrollingViewController.swift @@ -63,8 +63,8 @@ open class ScrollingViewController: ViewController { registerForKeyboardNotifications() } - open override func updateUI() { - super.updateUI() + open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { + super.updateUI(for: molecules) // will change scrollView indicatorStyle automatically on the basis of backgroundColor var greyScale: CGFloat = 0 if view.backgroundColor?.getWhite(&greyScale, alpha: nil) ?? false { diff --git a/MVMCoreUI/BaseControllers/ThreeLayerCollectionViewController.swift b/MVMCoreUI/BaseControllers/ThreeLayerCollectionViewController.swift index 13b62f2b..22fd7778 100644 --- a/MVMCoreUI/BaseControllers/ThreeLayerCollectionViewController.swift +++ b/MVMCoreUI/BaseControllers/ThreeLayerCollectionViewController.swift @@ -108,8 +108,8 @@ import Foundation } } - open override func updateUI() { - super.updateUI() + open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { + super.updateUI(for: molecules) topView?.removeFromSuperview() bottomView?.removeFromSuperview() topView = viewForTop() diff --git a/MVMCoreUI/BaseControllers/ThreeLayerTableViewController.swift b/MVMCoreUI/BaseControllers/ThreeLayerTableViewController.swift index f452fa96..95448c10 100644 --- a/MVMCoreUI/BaseControllers/ThreeLayerTableViewController.swift +++ b/MVMCoreUI/BaseControllers/ThreeLayerTableViewController.swift @@ -50,8 +50,11 @@ open class ThreeLayerTableViewController: ProgrammaticTableViewController { tableView.reloadData() } - open override func updateUI() { - super.updateUI() + open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { + super.updateUI(for: molecules) + + guard molecules == nil else { return } + createViewForTableHeader() createViewForTableFooter() tableView?.reloadData() diff --git a/MVMCoreUI/BaseControllers/ThreeLayerViewController.swift b/MVMCoreUI/BaseControllers/ThreeLayerViewController.swift index 0ec3cac9..378dc0bc 100644 --- a/MVMCoreUI/BaseControllers/ThreeLayerViewController.swift +++ b/MVMCoreUI/BaseControllers/ThreeLayerViewController.swift @@ -49,8 +49,8 @@ open class ThreeLayerViewController: ProgrammaticScrollViewController { } } - open override func updateUI() { - super.updateUI() + open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { + super.updateUI(for: molecules) // Removes the views topView?.removeFromSuperview() diff --git a/MVMCoreUI/BaseControllers/ViewController.swift b/MVMCoreUI/BaseControllers/ViewController.swift index fdeeeed9..a91b37df 100644 --- a/MVMCoreUI/BaseControllers/ViewController.swift +++ b/MVMCoreUI/BaseControllers/ViewController.swift @@ -44,6 +44,13 @@ import MVMCore public var selectedField: UIView? + public var pageUpdateQueue: OperationQueue = { + let pageUpdateQueue = OperationQueue() + pageUpdateQueue.maxConcurrentOperationCount = 1 + pageUpdateQueue.qualityOfService = .userInteractive + return pageUpdateQueue + }() + /// Checks if the screen width has changed open func screenSizeChanged() -> Bool { !MVMCoreGetterUtility.cgfequalwiththreshold(previousScreenSize.width, view.bounds.size.width, 0.1) @@ -58,10 +65,6 @@ import MVMCore (pagesToListenFor()?.count ?? 0 > 0 || modulesToListenFor()?.count ?? 0 > 0) else { return } - let pageUpdateQueue = OperationQueue() - pageUpdateQueue.maxConcurrentOperationCount = 1 - pageUpdateQueue.qualityOfService = .userInteractive - observingForResponses = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil, queue: pageUpdateQueue, using: responseJSONUpdated(notification:)) } @@ -241,8 +244,9 @@ import MVMCore } /// Applies the latest model to the UI. - @MainActor - open func updateUI() { + open func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { + guard molecules == nil else { return } + executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in behavior.willRender(rootMolecules: getRootMolecules(), delegateObjectIVar) } @@ -508,6 +512,40 @@ import MVMCore // Needed otherwise when subclassed, the extension gets called. open func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) { } + public func replaceMoleculeData(_ moleculeModels: [MoleculeModelProtocol]) { + pageUpdateQueue.addOperation { + let replacedModels:[MoleculeModelProtocol] = moleculeModels.compactMap { model in + guard self.attemptToReplace(with: model) else { + return nil + } + return model + } + if replacedModels.count > 0 { + Task { @MainActor in + self.updateUI(for: replacedModels) + } + } + } + } + + open func attemptToReplace(with replacementModel: MoleculeModelProtocol) -> Bool { + guard var templateModel = getTemplateModel() else { return false } + var didReplace = false + do { + didReplace = try templateModel.replaceMolecule(with: replacementModel) + if !didReplace { + MVMCoreLoggingHandler.addError(toLog: MVMCoreErrorObject(title: nil, messageToLog: "Failed to find '\(replacementModel.id)' in the current screen.", code: ErrorCode.viewControllerProcessingJSON.rawValue, domain: ErrorDomainSystem, location: String(describing: type(of: self)))!) + } + } catch { + let coreError = MVMCoreErrorObject.createErrorObject(for: error, location: String(describing: type(of: self)))! + if let error = error as? HumanReadableDecodingErrorProtocol { + coreError.messageToLog = "Error replacing molecule \"\(replacementModel.id)\": \(error.readableDescription)" + } + MVMCoreLoggingHandler.addError(toLog: coreError) + } + return didReplace + } + //-------------------------------------------------- // MARK: - MVMCoreUIDetailViewProtocol //-------------------------------------------------- diff --git a/MVMCoreUI/Behaviors/ReplaceableMoleculeBehaviorModel.swift b/MVMCoreUI/Behaviors/ReplaceableMoleculeBehaviorModel.swift index d4d17c0d..0b4c0516 100644 --- a/MVMCoreUI/Behaviors/ReplaceableMoleculeBehaviorModel.swift +++ b/MVMCoreUI/Behaviors/ReplaceableMoleculeBehaviorModel.swift @@ -17,39 +17,89 @@ public class ReplaceableMoleculeBehaviorModel: PageBehaviorModelProtocol { public class ReplaceableMoleculeBehavior: PageMoleculeTransformationBehavior { var moleculeIds: [String] + var modulesToListenFor: [String] + private var observingForResponses: NSObjectProtocol? + private var delegateObject: MVMCoreUIDelegateObject? public var transcendsPageUpdates: Bool { true } 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() + } + self.delegateObject = delegateObject guard let pageType = delegateObject?.moleculeDelegate?.getTemplateModel()?.pageType else { return } MVMCoreViewControllerMappingObject.shared()?.addOptionalModules(toMapping: moleculeIds, forPageType: pageType) } public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { + self.delegateObject = delegateObject + let shouldListenForListUpdates = delegateObject?.moleculeListDelegate != nil + if shouldListenForListUpdates { + modulesToListenFor = [] + listenForModuleUpdates() + } else { + modulesToListenFor = moleculeIds + stopListeningForModuleUpdates() + } - guard var templateModel = delegateObject?.moleculeDelegate?.getTemplateModel() else { return } - - templateModel.printMolecules() - - for moleculeId in moleculeIds { + let moleculeModels = moleculeIds.compactMap { moleculeId in do { - guard let replacementModel = try delegateObject?.moleculeDelegate?.getModuleWithName(moleculeId) else { continue } - let didReplace = try templateModel.replaceMolecule(with: replacementModel) - if !didReplace { - MVMCoreLoggingHandler.addError(toLog: MVMCoreErrorObject(title: nil, messageToLog: "Failed to find '\(moleculeId)' in the current screen.", code: ErrorCode.viewControllerProcessingJSON.rawValue, domain: ErrorDomainSystem, location: String(describing: type(of: self)))!) - } + return try delegateObject?.moleculeDelegate?.getModuleWithName(moleculeId) } 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)" } MVMCoreLoggingHandler.addError(toLog: coreError) + return nil } } + delegateObject?.moleculeDelegate?.replaceMoleculeData(moleculeModels) } - public func modulesToListenFor() -> [String] { - moleculeIds + 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, using: responseJSONUpdated(notification:)) + } + + private func stopListeningForModuleUpdates() { + guard let observingForResponses = observingForResponses else { return } + NotificationCenter.default.removeObserver(observingForResponses) + } + + @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)" + } + MVMCoreLoggingHandler.addError(toLog: coreError) + return nil + } + } + delegateObject?.moleculeDelegate?.replaceMoleculeData(modules) + } + + 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)) + } + return try modelType.decode(jsonDict: moduleJSON as [String : Any]) as! MoleculeModelProtocol } }