Digital PCT265 story PCT-135: Inline replacement updates with the core render loop.

This commit is contained in:
Hedden, Kyle Matthew 2024-05-13 14:46:35 -04:00
parent 9de1437edc
commit f37e7abcb1
18 changed files with 266 additions and 152 deletions

View File

@ -261,8 +261,9 @@ open class AccessibilityHandlerBehavior: PageVisibilityBehavior, PageMoleculeTra
accessibilityHandler = AccessibilityHandler.shared() //Protocol Mandatory init method. 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) accessibilityHandler?.onPageNew(rootMolecules: rootMolecules, delegateObject)
return nil
} }
open func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) { open func willShowPage(_ delegateObject: MVMCoreUIDelegateObject?) {

View File

@ -145,4 +145,16 @@ open class CarouselIndicatorModel: CarouselPagingModelProtocol, MoleculeModelPro
&& indicatorColor == model.indicatorColor && indicatorColor == model.indicatorColor
&& position == model.position && 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
}
} }

View File

@ -171,14 +171,20 @@ open class Carousel: View {
guard let carouselModel = model as? CarouselModel else { return } 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 #available(iOS 15.0, *) {
if let originalModel, carouselModel.isVisuallyEquivalent(to: originalModel) { 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) FormValidator.setupValidation(for: carouselModel, delegate: delegateObject?.formHolderDelegate)
pagingView?.currentIndex = originalModel.index // Trigger a paging view render.
collectionView.reconfigureItems(at: collectionView.indexPathsForVisibleItems)
return return
} }
} }
MVMCoreLoggingHandler.shared()?.handleDebugMessage("[\(Self.self)] Model is new. Rebuilding carousel.")
accessibilityLabel = carouselModel.accessibilityText accessibilityLabel = carouselModel.accessibilityText
collectionView.layer.borderColor = UIColor.mvmCoolGray3.cgColor collectionView.layer.borderColor = UIColor.mvmCoolGray3.cgColor
collectionView.layer.borderWidth = (carouselModel.border ?? false) ? 1 : 0 collectionView.layer.borderWidth = (carouselModel.border ?? false) ? 1 : 0

View File

@ -228,3 +228,11 @@ extension CarouselModel {
} }
} }
extension CarouselModel: CustomDebugStringConvertible {
public var debugDescription: String {
return "\(molecules.count) \(molecules.map { ($0 as? CarouselItemModel)?.molecule.moleculeName ?? "unknown" } )"
}
}

View File

@ -126,14 +126,16 @@ extension ParentModelProtocol {
public typealias DeepCompareResult = (matched: Bool, myChild: ModelProtocol?, theirChild: ModelProtocol?) public typealias DeepCompareResult = (matched: Bool, myChild: ModelProtocol?, theirChild: ModelProtocol?)
public typealias ModelPair = (mine: ModelProtocol, theirs: ModelProtocol)
public func deepEquals(to model: any ModelProtocol) -> DeepCompareResult { public func deepEquals(to model: any ModelProtocol) -> DeepCompareResult {
guard let model = model as? ParentModelProtocol else { return (false, self, model) } 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 myChildren = children
let theirChildren = anotherParent.children let theirChildren = anotherParent.children
@ -141,7 +143,7 @@ extension ParentModelProtocol {
for index in myChildren.indices { for index in myChildren.indices {
if let myChild = myChildren[index] as? ParentModelProtocol { if let myChild = myChildren[index] as? ParentModelProtocol {
if let theirChild = theirChildren[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 } guard result.0 else { return result }
} else { } else {
return (false, myChild: myChild, theirChild: theirChildren[index]) return (false, myChild: myChild, theirChild: theirChildren[index])
@ -153,4 +155,26 @@ extension ParentModelProtocol {
return (true, nil, nil) 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
}
} }

View File

@ -10,6 +10,8 @@
public protocol TemplateModelProtocol: PageModelProtocol, ModelProtocol, MoleculeTreeTraversalProtocol, ParentModelProtocol { public protocol TemplateModelProtocol: PageModelProtocol, ModelProtocol, MoleculeTreeTraversalProtocol, ParentModelProtocol {
var template: String { get } var template: String { get }
var rootMolecules: [MoleculeModelProtocol] { get } var rootMolecules: [MoleculeModelProtocol] { get }
/// Page rendering ID. Unique betwen JSON parses.
var id: String { get }
} }
public extension TemplateModelProtocol { public extension TemplateModelProtocol {

View File

@ -35,19 +35,26 @@ public extension TemplateProtocol {
public extension TemplateProtocol where Self: PageBehaviorHandlerProtocol, Self: MVMCoreViewControllerProtocol { 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. /// Helper function to do common parsing logic.
func parseTemplate(json: [AnyHashable: Any]?) throws { func parseTemplate(pageJSON: [AnyHashable: Any]) throws -> TemplateModelProtocol {
guard let pageJSON = json else { return }
let delegateObject = delegateObject?() as? MVMCoreUIDelegateObject let delegateObject = delegateObject?() as? MVMCoreUIDelegateObject
let data = try JSONSerialization.data(withJSONObject: pageJSON) let data = try JSONSerialization.data(withJSONObject: pageJSON)
let decoder = JSONDecoder.create(with: delegateObject) 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. // Add additional required behavior models to the template if applicable.
guard var pageBehaviorsModel = templateModel as? TemplateModelProtocol & PageBehaviorContainerModelProtocol else { return } guard var templateBehaviorsModel = templateModel as? TemplateModelProtocol & PageBehaviorContainerModelProtocol else {
return templateModel
}
templateBehaviorsModel.traverseAndAddRequiredBehaviors()
pageBehaviorsModel.traverseAndAddRequiredBehaviors() return templateModel
var behaviorHandler = self
behaviorHandler.applyBehaviors(pageBehaviorModel: pageBehaviorsModel, delegateObject: delegateObject)
} }
} }

View File

@ -10,11 +10,14 @@ import Foundation
@objcMembers open class BaseTemplateModel: MVMControllerModelProtocol, TabPageModelProtocol { @objcMembers open class BaseTemplateModel: MVMControllerModelProtocol, TabPageModelProtocol {
//-------------------------------------------------- //--------------------------------------------------
// MARK: - Properties // MARK: - Properties
//-------------------------------------------------- //--------------------------------------------------
open class var identifier: String { "" } open class var identifier: String { "" }
public var id: String = UUID().uuidString
public var pageType: String public var pageType: String
public var template: String { public var template: String {
// Although this is done in the extension, it is needed for the encoding. // Although this is done in the extension, it is needed for the encoding.

View File

@ -21,9 +21,8 @@
//-------------------------------------------------- //--------------------------------------------------
// MARK: - Computed Properties // MARK: - Computed Properties
//-------------------------------------------------- //--------------------------------------------------
open override func parsePageJSON() throws { open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol {
try parseTemplate(json: loadObject?.pageJSON) return try parseTemplate(loadObject: loadObject)
try super.parsePageJSON()
} }
open override var loadObject: MVMCoreLoadObject? { open override var loadObject: MVMCoreLoadObject? {
@ -80,10 +79,10 @@
} }
open override func handleNewData() { open override func handleNewData(_ pageModel: PageModelProtocol? = nil) {
setup() setup()
registerCells() registerCells()
super.handleNewData() super.handleNewData(pageModel)
} }
open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) {

View File

@ -25,8 +25,8 @@ open class ModalMoleculeListTemplate: MoleculeListTemplate {
try decoder.decode(ModalListPageTemplateModel.self, from: data) try decoder.decode(ModalListPageTemplateModel.self, from: data)
} }
override open func handleNewData() { override open func handleNewData(_ pageModel: PageModelProtocol? = nil) {
super.handleNewData() super.handleNewData(pageModel)
closeButton = MVMCoreUICommonViewsUtility.addCloseButton(to: view, action: { [weak self] _ in closeButton = MVMCoreUICommonViewsUtility.addCloseButton(to: view, action: { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }

View File

@ -23,8 +23,8 @@ open class ModalMoleculeStackTemplate: MoleculeStackTemplate {
// MARK: - Lifecycle // MARK: - Lifecycle
//-------------------------------------------------- //--------------------------------------------------
override open func handleNewData() { override open func handleNewData(_ pageModel: PageModelProtocol? = nil) {
super.handleNewData() super.handleNewData(pageModel)
_ = MVMCoreUICommonViewsUtility.addCloseButton(to: view, action: { [weak self] _ in _ = MVMCoreUICommonViewsUtility.addCloseButton(to: view, action: { [weak self] _ in
guard let self = self else { return } guard let self = self else { return }
let closeAction = (self.templateModel as? ModalStackPageTemplateModel)?.closeAction ?? let closeAction = (self.templateModel as? ModalStackPageTemplateModel)?.closeAction ??

View File

@ -46,9 +46,8 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol
// MARK: - Methods // MARK: - Methods
//-------------------------------------------------- //--------------------------------------------------
open override func parsePageJSON() throws { open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol {
try parseTemplate(json: loadObject?.pageJSON) return try parseTemplate(loadObject: loadObject)
try super.parsePageJSON()
} }
// For subclassing the model. // For subclassing the model.
@ -86,15 +85,16 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol
return view return view
} }
open override func handleNewData() { open override func handleNewData(_ pageModel: PageModelProtocol? = nil) {
super.handleNewData(pageModel)
setup() setup()
registerWithTable() registerWithTable()
super.handleNewData() // Currently stuck as MoleculeListProtocol being called from AddRemoveMoleculesBehaviorModel.
} }
open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) {
topViewOutsideOfScrollArea = templateModel?.anchorHeader ?? false topViewOutsideOfScrollArea = templateModel?.anchorHeader ?? false
bottomViewOutsideOfScrollArea = templateModel?.anchorFooter ?? false bottomViewOutsideOfScrollArea = templateModel?.anchorFooter ?? false
super.updateUI(for: molecules) super.updateUI(for: molecules)
guard let molecules else { return } guard let molecules else { return }

View File

@ -20,10 +20,10 @@ open class MoleculeStackTemplate: ThreeLayerViewController, TemplateProtocol {
// MARK: - Lifecycle // MARK: - Lifecycle
//-------------------------------------------------- //--------------------------------------------------
open override func handleNewData() { open override func handleNewData(_ pageModel: PageModelProtocol? = nil) {
topViewOutsideOfScroll = templateModel?.anchorHeader ?? false topViewOutsideOfScroll = templateModel?.anchorHeader ?? false
bottomViewOutsideOfScroll = templateModel?.anchorFooter ?? false bottomViewOutsideOfScroll = templateModel?.anchorFooter ?? false
super.handleNewData() super.handleNewData(pageModel)
} }
// For subclassing the model. // For subclassing the model.
@ -31,9 +31,8 @@ open class MoleculeStackTemplate: ThreeLayerViewController, TemplateProtocol {
return try decoder.decode(StackPageTemplateModel.self, from: data) return try decoder.decode(StackPageTemplateModel.self, from: data)
} }
open override func parsePageJSON() throws { open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol {
try parseTemplate(json: loadObject?.pageJSON) return try parseTemplate(loadObject: loadObject)
try super.parsePageJSON()
} }
open override var loadObject: MVMCoreLoadObject? { open override var loadObject: MVMCoreLoadObject? {

View File

@ -14,9 +14,8 @@ import UIKit
// MARK: - Lifecycle // MARK: - Lifecycle
//-------------------------------------------------- //--------------------------------------------------
open override func parsePageJSON() throws { open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol {
try parseTemplate(json: loadObject?.pageJSON) return try parseTemplate(loadObject: loadObject)
try super.parsePageJSON()
} }
open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) { open override func updateUI(for molecules: [MoleculeModelProtocol]? = nil) {

View File

@ -40,6 +40,7 @@ import MVMCore
public var needsUpdateUI = false public var needsUpdateUI = false
private var observingForResponses: NSObjectProtocol? private var observingForResponses: NSObjectProtocol?
private var initialLoadFinished = false private var initialLoadFinished = false
private var isFirstRender = true
public var previousScreenSize = CGSize.zero public var previousScreenSize = CGSize.zero
public var selectedField: UIView? public var selectedField: UIView?
@ -83,14 +84,16 @@ import MVMCore
open func modulesToListenFor() -> [String]? { open func modulesToListenFor() -> [String]? {
let requestModules = loadObject?.requestParameters?.allModules() ?? [] let requestModules = loadObject?.requestParameters?.allModules() ?? []
let behaviorModules = behaviors?.flatMap { $0.modulesToListenFor() } ?? [] let behaviorModules = behaviors?.flatMap { $0.modulesToListenFor } ?? []
return requestModules + behaviorModules return requestModules + behaviorModules
} }
@objc open func responseJSONUpdated(notification: Notification) { @objc open func responseJSONUpdated(notification: Notification) {
// Checks for a page we are listening for. // 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), if let pagesLoaded = notification.userInfo?.optionalDictionaryForKey(KeyPageMap),
let loadObject,
let pageType = pagesToListenFor()?.first(where: { (pageTypeListened) -> Bool in let pageType = pagesToListenFor()?.first(where: { (pageTypeListened) -> Bool in
guard let page = pagesLoaded.optionalDictionaryForKey(pageTypeListened), guard let page = pagesLoaded.optionalDictionaryForKey(pageTypeListened),
let pageType = page.optionalStringForKey(KeyPageType), let pageType = page.optionalStringForKey(KeyPageType),
@ -99,8 +102,21 @@ import MVMCore
return true return true
}) { }) {
newData = true hasDataUpdate = true
loadObject?.pageJSON = pagesLoaded.optionalDictionaryForKey(pageType) 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. // Checks for modules we are listening for.
@ -108,7 +124,7 @@ import MVMCore
let modulesListened = modulesToListenFor() { let modulesListened = modulesToListenFor() {
for moduleName in modulesListened { for moduleName in modulesListened {
if let module = modulesLoaded.optionalDictionaryForKey(moduleName) { if let module = modulesLoaded.optionalDictionaryForKey(moduleName) {
newData = true hasDataUpdate = true
var currentModules = loadObject?.modulesJSON ?? [:] var currentModules = loadObject?.modulesJSON ?? [:]
currentModules.updateValue(module, forKey: moduleName) currentModules.updateValue(module, forKey: moduleName)
loadObject?.modulesJSON = currentModules loadObject?.modulesJSON = currentModules
@ -116,21 +132,11 @@ import MVMCore
} }
} }
guard newData else { return } guard hasDataUpdate else { return }
do { MVMCoreDispatchUtility.performBlock(onMainThread: {
// TODO: Parse parsePageJSON modifies the page model on a different thread than self.handleNewData(pageModel)
// 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)
}
}
} }
open func shouldFinishProcessingLoad(_ loadObject: MVMCoreLoadObject, error: AutoreleasingUnsafeMutablePointer<MVMCoreErrorObject?>) -> Bool { open func shouldFinishProcessingLoad(_ loadObject: MVMCoreLoadObject, error: AutoreleasingUnsafeMutablePointer<MVMCoreErrorObject?>) -> Bool {
@ -142,7 +148,12 @@ import MVMCore
// Parse the model for the page. // Parse the model for the page.
do { 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 { } catch let parsingError {
// Log all parsing errors and fail load. // Log all parsing errors and fail load.
if let errorObject = MVMCoreLoadHandler.sharedGlobal()?.error(for: loadObject, causedBy: parsingError) { 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)" return "Error parsing template. \((parsingError as NSError).localizedFailureReason ?? parsingError.localizedDescription)"
} }
open func parsePageJSON() throws { open func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol {
if let backgroundRequest = loadObject?.requestParameters?.backgroundRequest, !backgroundRequest, let pageType, let identifier = loadObject?.identifier { throw MVMCoreError.error(code: ErrorCode.parsingJSON.rawValue, messageToDisplay: "Template needs to define its model!", messageToLog: "Template needs to define its model!")
MVMCoreLoggingHandler.shared()?.logCoreEvent(.pageProcessingComplete(pageType: pageType, requestUUID: identifier, webUrl: nil))
}
} }
open class func verifyRequiredModulesLoaded(for loadObject: MVMCoreLoadObject?, error: AutoreleasingUnsafeMutablePointer<MVMCoreErrorObject?>) -> Bool { open class func verifyRequiredModulesLoaded(for loadObject: MVMCoreLoadObject?, error: AutoreleasingUnsafeMutablePointer<MVMCoreErrorObject?>) -> 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. /// 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 @MainActor
open func handleNewData() { open func handleNewData(_ pageModel: PageModelProtocol? = nil) {
if model?.navigationBar == 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() let navigationItem = createDefaultLegacyNavigationModel()
model?.navigationBar = navigationItem newPageModel.navigationBar = navigationItem
} }
executeBehaviors { (behavior: PageMoleculeTransformationBehavior) in self.pageModel = newPageModel
behavior.onPageNew(rootMolecules: getRootMolecules(), delegateObjectIVar)
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 { if formValidator == nil { // TODO: Can't change form rules?
let rules = model?.formRules let rules = (newPageModel as? MVMControllerModelProtocol)?.formRules
formValidator = FormValidator(rules) formValidator = FormValidator(rules)
} }
updateUI() self.pageModel = newPageModel
// Notify the manager of new data. /// Run through the differences between separate page model trees.
// Warning: Some flows cause table reloads. Until the UI update is decoupled, should be after the updateUI. var pageUpdatedModels = [MoleculeModelProtocol]()
manager?.newDataReceived?(in: self) 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. /// Applies the latest model to the UI.
@ -312,7 +372,7 @@ import MVMCore
super.viewDidLoad() super.viewDidLoad()
// Do any additional setup after loading the view. // Do any additional setup after loading the view.
MVMCoreLoggingHandler.logDebugMessage(withDelegate: "View Controller Loaded : \(self)") debugLog("View Controller Loaded")
// We use our own margins. // We use our own margins.
viewRespectsSystemMinimumLayoutMargins = false viewRespectsSystemMinimumLayoutMargins = false
@ -326,7 +386,7 @@ import MVMCore
initialLoad() initialLoad()
} }
handleNewData() handleNewData(pageModel) // Set outside shouldFinishProcessingLoad.
} }
open override func viewDidLayoutSubviews() { open override func viewDidLayoutSubviews() {
@ -395,7 +455,7 @@ import MVMCore
deinit { deinit {
stopObservingForResponseJSONUpdates() stopObservingForResponseJSONUpdates()
MVMCoreLoggingHandler.logDebugMessage(withDelegate: "View Controller Deallocated : \(self)") debugLog("Deallocated")
} }
open override var supportedInterfaceOrientations: UIInterfaceOrientationMask { open override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
@ -514,22 +574,22 @@ import MVMCore
open func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) { } open func moleculeLayoutUpdated(_ molecule: MoleculeViewProtocol) { }
public func replaceMoleculeData(_ moleculeModels: [MoleculeModelProtocol], completionHandler: (([MoleculeModelProtocol])->Void)? = nil) { public func replaceMoleculeData(_ moleculeModels: [MoleculeModelProtocol], completionHandler: (([MoleculeModelProtocol])->Void)? = nil) {
pageUpdateQueue.addOperation { pageUpdateQueue.addOperation { [self] in
let replacedModels:[(MoleculeModelProtocol, MoleculeModelProtocol)] = moleculeModels.compactMap { model 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 nil
} }
return (model, replacedMolecule) return (model, replacedMolecule)
} }
let uiUpdatedModels: [MoleculeModelProtocol] = replacedModels.compactMap { new, existing in let uiUpdatedModels: [MoleculeModelProtocol] = replacedModels.compactMap { new, existing in
guard !new.isEqual(to: existing) else { 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 nil
} }
return new return new
} }
if uiUpdatedModels.count > 0 { if uiUpdatedModels.count > 0 {
MVMCoreLoggingHandler.shared()?.handleDebugMessage("Updating UI for molecules: \(uiUpdatedModels)") debugLog("Updating UI for molecules: \(uiUpdatedModels)")
DispatchQueue.main.sync { DispatchQueue.main.sync {
self.updateUI(for: uiUpdatedModels) 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"
}
}

View File

@ -68,7 +68,7 @@ public class PollingBehavior: NSObject, PageVisibilityBehavior, PageMoleculeTran
model.refreshInterval + lastRefresh.timeIntervalSinceNow // timeIntervalSinceNow in negative since earlier recording (--) 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 { var refreshOnShown: Bool {
if model.refreshOnFirstLoad && firstTimeLoad { if model.refreshOnFirstLoad && firstTimeLoad {
@ -84,11 +84,12 @@ public class PollingBehavior: NSObject, PageVisibilityBehavior, PageMoleculeTran
Self.debugLog("Initializing for \(model)") 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 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. // 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) resumePollingTimer(withRemainingTime: remainingTimeToRefresh, refreshAction: model.refreshAction, interval: model.refreshInterval)
} }
return nil
} }
public func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) { public func onPageShown(_ delegateObject: MVMCoreUIDelegateObject?) {

View File

@ -14,7 +14,7 @@ public protocol PageBehaviorProtocol: ModelHandlerProtocol {
/// Should the behavior persist regardless of page behavior model updates. /// Should the behavior persist regardless of page behavior model updates.
var transcendsPageUpdates: Bool { get } var transcendsPageUpdates: Bool { get }
func modulesToListenFor() -> [String] var modulesToListenFor: [String] { get }
/// Initializes the behavior with the model /// Initializes the behavior with the model
init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?)
@ -22,7 +22,7 @@ public protocol PageBehaviorProtocol: ModelHandlerProtocol {
public extension PageBehaviorProtocol { public extension PageBehaviorProtocol {
var transcendsPageUpdates: Bool { return false } var transcendsPageUpdates: Bool { return false }
func modulesToListenFor() -> [String] { return [] } var modulesToListenFor: [String] { return [] }
} }
/** /**
@ -30,7 +30,7 @@ public extension PageBehaviorProtocol {
*/ */
public protocol PageMoleculeTransformationBehavior: 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 willSetupMolecule(with model: MoleculeModelProtocol, updating view: MoleculeViewProtocol?)
func didSetupMolecule(view: MoleculeViewProtocol, withModel: MoleculeModelProtocol) func didSetupMolecule(view: MoleculeViewProtocol, withModel: MoleculeModelProtocol)
func willSetupNavigationBar(with model: NavigationItemModelProtocol, updating view: UINavigationBar) func willSetupNavigationBar(with model: NavigationItemModelProtocol, updating view: UINavigationBar)
@ -41,7 +41,7 @@ public protocol PageMoleculeTransformationBehavior: PageBehaviorProtocol {
public extension PageMoleculeTransformationBehavior { public extension PageMoleculeTransformationBehavior {
// All optional. // 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 willSetupMolecule(with model: MoleculeModelProtocol, updating view: MoleculeViewProtocol?) {}
func didSetupMolecule(view: MoleculeViewProtocol, withModel: MoleculeModelProtocol) {} func didSetupMolecule(view: MoleculeViewProtocol, withModel: MoleculeModelProtocol) {}
func willSetupNavigationBar(with model: NavigationItemModelProtocol, updating view: UINavigationBar) {} func willSetupNavigationBar(with model: NavigationItemModelProtocol, updating view: UINavigationBar) {}

View File

@ -18,7 +18,7 @@ public class ReplaceableMoleculeBehaviorModel: PageBehaviorModelProtocol {
public class ReplaceableMoleculeBehavior: PageMoleculeTransformationBehavior, CoreLogging { public class ReplaceableMoleculeBehavior: PageMoleculeTransformationBehavior, CoreLogging {
public var loggingPrefix: String { 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? { public static var loggingCategory: String? {
@ -26,37 +26,22 @@ public class ReplaceableMoleculeBehavior: PageMoleculeTransformationBehavior, Co
} }
var moleculeIds: [String] var moleculeIds: [String]
var modulesToListenFor: [String] public var modulesToListenFor: [String]
private var observingForResponses: NSObjectProtocol? private var observingForResponses: NSObjectProtocol?
private var delegateObject: MVMCoreUIDelegateObject? private var delegateObject: MVMCoreUIDelegateObject?
public required init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) { public required init(model: PageBehaviorModelProtocol, delegateObject: MVMCoreUIDelegateObject?) {
moleculeIds = (model as! ReplaceableMoleculeBehaviorModel).moleculeIds moleculeIds = (model as! ReplaceableMoleculeBehaviorModel).moleculeIds
let shouldListenForListUpdates = delegateObject?.moleculeListDelegate != nil modulesToListenFor = moleculeIds
if shouldListenForListUpdates {
modulesToListenFor = []
listenForModuleUpdates()
} else {
modulesToListenFor = moleculeIds
stopListeningForModuleUpdates()
}
self.delegateObject = delegateObject self.delegateObject = delegateObject
guard let pageType = delegateObject?.moleculeDelegate?.getTemplateModel()?.pageType else { return } guard let pageType = delegateObject?.moleculeDelegate?.getTemplateModel()?.pageType else { return }
MVMCoreViewControllerMappingObject.shared()?.addOptionalModules(toMapping: moleculeIds, forPageType: pageType) MVMCoreViewControllerMappingObject.shared()?.addOptionalModules(toMapping: moleculeIds, forPageType: pageType)
Self.debugLog("Initializing for \((model as! ReplaceableMoleculeBehaviorModel).moleculeIds)") Self.debugLog("Initializing for \((model as! ReplaceableMoleculeBehaviorModel).moleculeIds)")
} }
public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { public func onPageNew(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) -> [MoleculeModelProtocol]? {
debugLog("onPageNew")
self.delegateObject = delegateObject self.delegateObject = delegateObject
let shouldListenForListUpdates = delegateObject?.moleculeListDelegate != nil modulesToListenFor = moleculeIds
if shouldListenForListUpdates {
modulesToListenFor = []
listenForModuleUpdates()
} else {
modulesToListenFor = moleculeIds
stopListeningForModuleUpdates()
}
let moleculeModels = moleculeIds.compactMap { moleculeId in let moleculeModels = moleculeIds.compactMap { moleculeId in
do { do {
@ -70,65 +55,61 @@ public class ReplaceableMoleculeBehavior: PageMoleculeTransformationBehavior, Co
return nil 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? return findAndReplace(moleculeModels, in: rootMolecules)
delegateObject?.moleculeDelegate?.replaceMoleculeData(moleculeModels, completionHandler: nil)
}
} }
private func listenForModuleUpdates() { fileprivate func findAndReplace(_ moleculeModels: [any MoleculeModelProtocol], in rootMolecules: [any MoleculeModelProtocol]) -> [any MoleculeModelProtocol]? {
guard observingForResponses == nil else { return } debugLog("onPageNew replacing \(moleculeModels.map { $0.id })")
let pageUpdateQueue = OperationQueue() var hasReplacement = false
pageUpdateQueue.maxConcurrentOperationCount = 1 let updatedRootMolecules = rootMolecules.map { rootMolecule in
pageUpdateQueue.qualityOfService = .userInteractive
observingForResponses = NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: NotificationResponseLoaded), object: nil, queue: pageUpdateQueue) { [weak self] notification in // Top level check to return a new root molecule.
self?.responseJSONUpdated(notification: notification) 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
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)"
} }
MVMCoreLoggingHandler.shared()?.addError(toLog: coreError) debugLog("onPageNew replacing \(rootMolecule) with \(updatedMolecule)")
return nil 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 } return hasReplacement ? updatedRootMolecules : nil
#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) }
}
} }
private func convertToModel(moduleJSON: [String: Any]) throws -> MoleculeModelProtocol { private func logUpdated(molecule: MoleculeModelProtocol) {
guard let moleculeName = moduleJSON.optionalStringForKey(KeyMoleculeName), guard let module: [AnyHashable: Any] = delegateObject?.moleculeDelegate?.getModuleWithName(molecule.id),
let modelType = ModelRegistry.getType(for: moleculeName, with: MoleculeModelProtocol.self) else { let viewController = delegateObject?.moleculeDelegate as? MVMCoreViewControllerProtocol else {
throw ModelRegistry.Error.decoderErrorModelNotMapped(identifer: moduleJSON.optionalStringForKey(KeyMoleculeName)) 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 { deinit {
debugLog("deinit") debugLog("deinit")
stopListeningForModuleUpdates()
} }
} }