diff --git a/MVMCoreUI/Atomic/Protocols/MoleculeListProtocol.swift b/MVMCoreUI/Atomic/Protocols/MoleculeListProtocol.swift index f5491568..d00ae005 100644 --- a/MVMCoreUI/Atomic/Protocols/MoleculeListProtocol.swift +++ b/MVMCoreUI/Atomic/Protocols/MoleculeListProtocol.swift @@ -14,25 +14,20 @@ public protocol MoleculeListProtocol { /// Asks the delegate to add molecules. func addMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], indexPath: IndexPath, animation: UITableView.RowAnimation?) - /// Asks the delegate to remove molecules. - func removeMolecules(at indexPaths: [IndexPath], animation: UITableView.RowAnimation?) + /// + func addMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], after molecule: (ListItemModelProtocol & MoleculeModelProtocol), animation: UITableView.RowAnimation?) + + /// Asks the delegate to remove molecules. Never ask to remove a molecule at an index. + //func removeMolecules(at indexPaths: [IndexPath], animation: UITableView.RowAnimation?) + + /// Asks the delegate to remove particular molecules from the list. + func removeMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], animation: UITableView.RowAnimation?) /// Asks the delegate to swap batches of molecules. func swapMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], with replacements: [ListItemModelProtocol & MoleculeModelProtocol], at indexPath: IndexPath, animation: UITableView.RowAnimation?) -} - -extension MoleculeListProtocol { - /// Convenience function that removes the passed molecule - public func removeMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], animation: UITableView.RowAnimation?) { - var indexPaths: [IndexPath] = [] - for molecule in molecules { - guard let indexPath = getIndexPath(for: molecule) else { continue } - indexPaths.append(indexPath) - } - if indexPaths.count > 0 { - removeMolecules(at: indexPaths, animation: animation) - } - } + + /// Asks the delegate to swap batches of molecules. + func swapMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], with replacements: [ListItemModelProtocol & MoleculeModelProtocol], after molecule: (ListItemModelProtocol & MoleculeModelProtocol)?, animation: UITableView.RowAnimation?) } public extension MVMCoreUIDelegateObject { diff --git a/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift b/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift index 2b78b7cd..939c0d7c 100644 --- a/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift @@ -8,16 +8,69 @@ import UIKit -public typealias MoleculeInfo = (identifier: String, class: AnyClass, molecule: MoleculeModelProtocol) +public struct MoleculeInfo: Hashable { + + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + public static func == (lhs: MoleculeInfo, rhs: MoleculeInfo) -> Bool { + lhs.id == rhs.id + } + + public let id: String + public let cellReuseId: String + public let cellType: MoleculeViewProtocol.Type + public var model: ListItemModelProtocol & MoleculeModelProtocol + + init?(listItemModel: ListItemModelProtocol & MoleculeModelProtocol, delegateObject: MVMCoreUIDelegateObject? = nil) { + guard let moleculeClass = ModelRegistry.getMoleculeClass(listItemModel) + else { return nil } + + self.model = listItemModel + self.id = model.id + self.cellType = moleculeClass + self.cellReuseId = moleculeClass.nameForReuse(with: listItemModel, delegateObject) ?? listItemModel.moleculeName + } + + func doesMatch(_ molecule: MoleculeModelProtocol) -> Bool { + id == molecule.id + } + + func contains(oneOf moleculeModels: [MoleculeModelProtocol]) -> MoleculeModelProtocol? { + model.findFirstMolecule(by: { existingMolecule in + moleculeModels.contains { moleculeModel in + existingMolecule.moleculeName == moleculeModel.moleculeName && existingMolecule.id == moleculeModel.id + } + }) + } +} +extension Array where Element == MoleculeInfo { + + func filter(byMolecules molecules: [MoleculeModelProtocol]) -> [MoleculeInfo] { + filter { listItemRef in molecules.contains { $0.id == listItemRef.model.id } } + } + + func registerTypes(with tableView: UITableView) { + forEach { tableView.register($0.cellType, forCellReuseIdentifier: $0.cellReuseId) } + } +} + +extension Array where Element == ListItemModelProtocol & MoleculeModelProtocol { + func asMoleculeInfoRef() -> [MoleculeInfo] { + compactMap { MoleculeInfo(listItemModel: $0) } + } +} open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol { + + public typealias ListDataSnapshot = NSDiffableDataSourceSnapshot + //-------------------------------------------------- // MARK: - Stored Properties //-------------------------------------------------- - public var moleculesInfo: [MoleculeInfo]? - var observer: NSKeyValueObservation? //-------------------------------------------------- @@ -46,6 +99,29 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol // MARK: - Methods //-------------------------------------------------- + public var dataSource: UITableViewDiffableDataSource! + + open override func createTableView() -> TableView { + let tableView = super.createTableView() + dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, moleculeInfo in + guard let self = self, + let cell = tableView.dequeueReusableCell(withIdentifier: moleculeInfo.cellReuseId) + else { return UITableViewCell() } + cell.isHidden = moleculeInfo.model.gone == true + (cell as? MoleculeViewProtocol)?.reset() + (cell as? MoleculeListCellProtocol)?.setLines(with: templateModel?.line, delegateObject: delegateObjectIVar, additionalData: nil, indexPath: indexPath) + if let moleculeView = cell as? MoleculeViewProtocol { + updateMoleculeView(moleculeView, from: moleculeInfo.model) + } + (cell as? MVMCoreViewProtocol)?.updateView(tableView.bounds.width) + // Neded to fix an apple defect where the cell is not the correct size on certain devices for certain cells + cell.setNeedsLayout() + return cell + } + tableView.dataSource = dataSource + return tableView + } + open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol { return try parseTemplate(loadObject: loadObject) } @@ -103,12 +179,6 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol guard let molecules else { return } // For updating individual specfied molecules. (Not a full table reload done in the base class.) These molecule types should remain the same type by replacement standards. - molecules.forEach({ molecule in - // Replace any top level cell data if required. - if let index = moleculesInfo?.firstIndex(where: { $0.molecule.id == molecule.id }) { - moleculesInfo?[index].molecule = molecule - } - }) newData(for: molecules) } @@ -140,46 +210,46 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol open override func registerWithTable() { super.registerWithTable() - guard let moleculesInfo = moleculesInfo else { return } - for moleculeInfo in moleculesInfo { - tableView?.register(moleculeInfo.class, forCellReuseIdentifier: moleculeInfo.identifier) + for moleculeInfo in dataSource.snapshot().itemIdentifiers { + tableView?.register(moleculeInfo.cellType, forCellReuseIdentifier: moleculeInfo.cellReuseId) } } open func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - return (getMoleculeInfo(for: indexPath)?.molecule as? GoneableProtocol)?.gone == true ? 0 : UITableView.automaticDimension + return (dataSource.itemIdentifier(for: indexPath)?.model as? GoneableProtocol)?.gone == true ? 0 : UITableView.automaticDimension } open func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard let moleculeInfo = getMoleculeInfo(for: indexPath), - let estimatedHeight = (moleculeInfo.class as? MoleculeViewProtocol.Type)?.estimatedHeight(with: moleculeInfo.molecule, delegateObject() as? MVMCoreUIDelegateObject) + guard let moleculeInfo = dataSource.itemIdentifier(for: indexPath), + let estimatedHeight = moleculeInfo.cellType.estimatedHeight(with: moleculeInfo.model, delegateObject() as? MVMCoreUIDelegateObject) else { return 0 } return estimatedHeight } - open override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - moleculesInfo?.count ?? 0 - } - - open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - - guard let moleculeInfo = getMoleculeInfo(for: indexPath), - let cell = tableView.dequeueReusableCell(withIdentifier: moleculeInfo.identifier) - else { return UITableViewCell() } - cell.isHidden = (getMoleculeInfo(for: indexPath)?.molecule as? GoneableProtocol)?.gone == true - (cell as? MoleculeViewProtocol)?.reset() - (cell as? MoleculeListCellProtocol)?.setLines(with: templateModel?.line, delegateObject: delegateObjectIVar, additionalData: nil, indexPath: indexPath) - if let moleculeView = cell as? MoleculeViewProtocol { - updateMoleculeView(moleculeView, from: moleculeInfo.molecule) - } - (cell as? MVMCoreViewProtocol)?.updateView(tableView.bounds.width) - // Neded to fix an apple defect where the cell is not the correct size on certain devices for certain cells - cell.setNeedsLayout() - return cell - } +// open override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { +// debugLog("Number of rows: \(moleculesInfo?.count ?? 0)") +// return moleculesInfo?.count ?? 0 +// } +// open override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { +// +// guard let moleculeInfo = getMoleculeInfo(for: indexPath), +// let cell = tableView.dequeueReusableCell(withIdentifier: moleculeInfo.identifier) +// else { return UITableViewCell() } +// cell.isHidden = (getMoleculeInfo(for: indexPath)?.molecule as? GoneableProtocol)?.gone == true +// (cell as? MoleculeViewProtocol)?.reset() +// (cell as? MoleculeListCellProtocol)?.setLines(with: templateModel?.line, delegateObject: delegateObjectIVar, additionalData: nil, indexPath: indexPath) +// if let moleculeView = cell as? MoleculeViewProtocol { +// updateMoleculeView(moleculeView, from: moleculeInfo.molecule) +// } +// (cell as? MVMCoreViewProtocol)?.updateView(tableView.bounds.width) +// // Neded to fix an apple defect where the cell is not the correct size on certain devices for certain cells +// cell.setNeedsLayout() +// return cell +// } + open func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { (cell as? MoleculeListCellProtocol)?.willDisplay() } @@ -235,7 +305,8 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol // If the view is in a cell, refresh the table ui. let point = molecule.convert(molecule.bounds.origin, to: tableView) if let indexPath = tableView.indexPathForRow(at: point), tableView.indexPathsForVisibleRows?.contains(indexPath) ?? false { - refreshTable() + //TOOD: Find alternate. + //refreshTable() } } @@ -254,29 +325,25 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol } } - guard let moleculesInfo = moleculesInfo else { return } - - let indicies = moleculesInfo.indices.filter({ index -> Bool in - return moleculesInfo[index].molecule.findFirstMolecule(by: { existingMolecule in - molecules.contains { newMolecule in - existingMolecule.moleculeName == newMolecule.moleculeName && equal(moleculeA: existingMolecule, moleculeB: newMolecule) - } - }) != nil - }) - - // Refresh the cell. (reload loses cell selection) - let selectedIndex = tableView.indexPathForSelectedRow - let indexPaths = indicies.map { - return IndexPath(row: $0, section: 0) + var snapshot = dataSource.snapshot() + let updatedListItems: [MoleculeInfo] = snapshot.itemIdentifiers.compactMap { listItemRef -> MoleculeInfo? in + var listItemRef = listItemRef + guard let matchedMolecule = listItemRef.contains(oneOf: molecules) else { return nil } + if let matchedMolecule = matchedMolecule as? (ListItemModelProtocol & MoleculeModelProtocol), listItemRef.doesMatch(matchedMolecule) { + listItemRef.model = matchedMolecule // Replace the top level molecule if it changed. + } + return listItemRef } + if #available(iOS 15.0, *) { + snapshot.reconfigureItems(updatedListItems) + } else { + // A full reload can cause a flicker / animation. Better to avoid with above reconfigure method. + snapshot.reloadItems(updatedListItems) + } + dataSource.apply(snapshot) - debugLog("Refreshing rows \(indexPaths.map { $0.row })") - - // All rows should have been layed out already on the first newDataBuildScreen reload with the getMoleculeInfoList call. Therefore, we can be safe to assume the top level cell configuration will not be modified and only the child content will be updated allowing us to leverage this more efficient method. - // A full reload can cause a flicker / animation when fetching an entirely new cell. Better to avoid with reconfigure method. - tableView.reconfigureRows(at: indexPaths) - - if let selectedIndex = selectedIndex { + // Refresh the cell. (reload loses cell selection). TODO: Check if necessary and timing. + if let selectedIndex = tableView.indexPathForSelectedRow { tableView.selectRow(at: selectedIndex, animated: false, scrollPosition: .none) } @@ -300,42 +367,38 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol // MARK: - Convenience //-------------------------------------------------- - /// Returns the (identifier, class) of the molecule for the given model. - func createMoleculeInfo(with listItem: MoleculeModelProtocol?) -> MoleculeInfo? { - - guard let listItem = listItem, - let moleculeClass = ModelRegistry.getMoleculeClass(listItem) - else { return nil } - - let moleculeName = moleculeClass.nameForReuse(with: listItem, delegateObject() as? MVMCoreUIDelegateObject) ?? listItem.moleculeName - - return (moleculeName, moleculeClass, listItem) - } - /// Returns the (identifier, class) of the molecule for the indexPath. - func getMoleculeInfo(for indexPath: IndexPath) -> MoleculeInfo? { - moleculesInfo?[safe: indexPath.row] - } +// func getMoleculeInfo(for indexPath: IndexPath) -> MoleculeInfo? { +// moleculesInfo?[safe: indexPath.row] +// } /// Sets up the molecule list and ensures no errors loading all content. - func getMoleculeInfoList() -> [MoleculeInfo]? { - - var moleculeList: [MoleculeInfo] = [] - - if let molecules = templateModel?.molecules { - for molecule in molecules { - if let info = createMoleculeInfo(with: molecule) { - moleculeList.append(info) - } - } - } - - return moleculeList.count > 0 ? moleculeList : nil - } +// func getMoleculeInfoList() -> [MoleculeInfo]? { +// +// var moleculeList: [MoleculeInfo] = [] +// +// if let molecules = templateModel?.molecules { +// for molecule in molecules { +// if let info = MoleculeInfo(with: molecule) { +// moleculeList.append(info) +// } +// } +// } +// +// return moleculeList.count > 0 ? moleculeList : nil +// } /// Sets up the header, footer, molecule list and ensures no errors loading all content. func setup() { - moleculesInfo = getMoleculeInfoList() + guard let listItems = templateModel?.molecules?.compactMap({ MoleculeInfo(listItemModel: $0, delegateObject: delegateObject() as? MVMCoreUIDelegateObject)}) else { + debugLog("There is no data to display.") + return + } + + var initialDataSnapshot = ListDataSnapshot() + initialDataSnapshot.appendSections([0]) + initialDataSnapshot.appendItems(listItems) + dataSource.apply(initialDataSnapshot) } /// Adds modules from requiredModules() to the MVMCoreViewControllerMapping.requiredModules map. @@ -368,90 +431,84 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol } extension MoleculeListTemplate: MoleculeListProtocol { - public func removeMolecules(at indexPaths: [IndexPath], animation: UITableView.RowAnimation?) { - for (index, indexPath) in indexPaths.sorted().enumerated() { - let removeIndex = indexPath.row - index - moleculesInfo?.remove(at: removeIndex) + +// public func removeMolecules(at indexPaths: [IndexPath], animation: UITableView.RowAnimation?) { +// for (index, indexPath) in indexPaths.sorted().enumerated() { +// let removeIndex = indexPath.row - index +// moleculesInfo?.remove(at: removeIndex) +// } +// +// guard let animation = animation, +// indexPaths.count > 0 else { return } +// tableView?.deleteRows(at: indexPaths, with: animation) +// updateViewConstraints() +// view.setNeedsLayout() +// } + + /// Convenience function that removes the passed molecule + public func removeMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], animation: UITableView.RowAnimation?) { + var snapshot = dataSource.snapshot() + snapshot.deleteItems(molecules.asMoleculeInfoRef()) + dataSource.apply(snapshot, animatingDifferences: true) { + self.updateViewConstraints() + self.view.setNeedsLayout() } - - guard let animation = animation, - indexPaths.count > 0 else { return } - tableView?.deleteRows(at: indexPaths, with: animation) - updateViewConstraints() - view.setNeedsLayout() } public func addMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], indexPath: IndexPath, animation: UITableView.RowAnimation?) { - var indexPaths: [IndexPath] = [] - - for molecule in molecules { - if let info = self.createMoleculeInfo(with: molecule) { - self.tableView?.register(info.class, forCellReuseIdentifier: info.identifier) - let index = indexPath.row + indexPaths.count - self.moleculesInfo?.insert(info, at: index) - indexPaths.append(IndexPath(row: index, section: 0)) - } + var snapshot = dataSource.snapshot() + guard let listItemRef = snapshot.itemIdentifiers[safe: indexPath.row] else { return } + snapshot.insertItems(molecules.asMoleculeInfoRef(), afterItem: listItemRef) + dataSource.apply(snapshot, animatingDifferences: true) { + self.updateViewConstraints() + self.view.setNeedsLayout() + } + } + + public func addMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], after molecule: (ListItemModelProtocol & MoleculeModelProtocol), animation: UITableView.RowAnimation?) { + guard let listMoleculeRef = MoleculeInfo(listItemModel: molecule) else { return } + var snapshot = dataSource.snapshot() + snapshot.insertItems(molecules.asMoleculeInfoRef(), afterItem: listMoleculeRef) + dataSource.apply(snapshot, animatingDifferences: true) { + self.updateViewConstraints() + self.view.setNeedsLayout() } - - guard let animation = animation, - indexPaths.count > 0 else { return } - self.tableView?.insertRows(at: indexPaths, with: animation) - self.updateViewConstraints() - self.view.setNeedsLayout() } public func swapMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], with replacements: [ListItemModelProtocol & MoleculeModelProtocol], at indexPath: IndexPath, animation: UITableView.RowAnimation?) { + swapMolecules(molecules, with: replacements, after: dataSource.snapshot().itemIdentifiers[safe: indexPath.row] as? (ListItemModelProtocol & MoleculeModelProtocol), animation: animation) + } - tableView.beginUpdates() + public func swapMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], with replacements: [ListItemModelProtocol & MoleculeModelProtocol], after targetMolecule: (ListItemModelProtocol & MoleculeModelProtocol)?, animation: UITableView.RowAnimation?) { - var indexPaths: [IndexPath] = [] - for molecule in molecules { - guard let indexPath = getIndexPath(for: molecule) else { continue } - indexPaths.append(indexPath) - } - for (index, indexPath) in indexPaths.sorted().enumerated() { - let removeIndex = indexPath.row - index - moleculesInfo?.remove(at: removeIndex) + guard let tableView else { return } + + let removingRefs = molecules.asMoleculeInfoRef() + + var snapshot = dataSource.snapshot() + + let replacementRefs = molecules.asMoleculeInfoRef() + replacementRefs.registerTypes(with: tableView) + + if let targetMolecule, let targetRef = MoleculeInfo(listItemModel: targetMolecule) { + snapshot.insertItems(replacementRefs, afterItem: targetRef) + } else if let targetRef = removingRefs.first { + snapshot.insertItems(replacementRefs, beforeItem: targetRef) } - var exitAnimation = UITableView.RowAnimation.automatic - switch animation { - case .left: exitAnimation = .right - case .right: exitAnimation = .left - case .top: exitAnimation = .bottom - case .bottom: exitAnimation = .top - default: break + snapshot.deleteItems(removingRefs) + + dataSource.apply(snapshot) { + self.updateViewConstraints() + self.view.setNeedsLayout() } - - if indexPaths.count > 0 { - tableView?.deleteRows(at: indexPaths, with: exitAnimation) - } - - indexPaths = [] - for molecule in replacements { - if let info = self.createMoleculeInfo(with: molecule) { - self.tableView?.register(info.class, forCellReuseIdentifier: info.identifier) - let index = indexPath.row + indexPaths.count - self.moleculesInfo?.insert(info, at: index) - indexPaths.append(IndexPath(row: index, section: 0)) - } - } - - if let animation = animation, - indexPaths.count > 0 { - self.tableView?.insertRows(at: indexPaths, with: animation) - } - - tableView.endUpdates() - - self.updateViewConstraints() - self.view.setNeedsLayout() } - public func getIndexPath(for molecule: ListItemModelProtocol & MoleculeModelProtocol) -> IndexPath? { - guard let index = moleculesInfo?.firstIndex(where: { (moleculeInfo) -> Bool in - return equal(moleculeA: molecule, moleculeB: moleculeInfo.molecule) - }) else { return nil } + public func getIndexPath(for molecule: any ListItemModelProtocol & MoleculeModelProtocol) -> IndexPath? { + let snapshot = dataSource.snapshot() + guard let listItemRef = MoleculeInfo(listItemModel: molecule), + let index = snapshot.indexOfItem(listItemRef) + else { return nil } return IndexPath(row: index, section: 0) } diff --git a/MVMCoreUI/Atomic/Templates/SectionListTemplate.swift b/MVMCoreUI/Atomic/Templates/SectionListTemplate.swift index 0feeadb8..8aa9fe7f 100644 --- a/MVMCoreUI/Atomic/Templates/SectionListTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/SectionListTemplate.swift @@ -37,7 +37,7 @@ open class SectionListTemplate: MoleculeListTemplate { sectionMoleculesInfo = sectionList.count > 0 ? sectionList : nil } - override func getMoleculeInfo(for indexPath: IndexPath) -> MoleculeInfo? { + func getMoleculeInfo(for indexPath: IndexPath) -> MoleculeInfo? { sectionMoleculesInfo?[indexPath.section].rows[indexPath.row] } @@ -137,4 +137,18 @@ open class SectionListTemplate: MoleculeListTemplate { open override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { sectionMoleculesInfo?[section].rows.count ?? 0 } + + func createMoleculeInfo(with listItem: MoleculeModelProtocol?) -> MoleculeInfo? { + + guard let listItem = listItem, + let moleculeClass = ModelRegistry.getMoleculeClass(listItem) + else { return nil } + + let moleculeName = moleculeClass.nameForReuse(with: listItem, delegateObject() as? MVMCoreUIDelegateObject) ?? listItem.moleculeName + + return (moleculeName, moleculeClass, listItem) + } + + public typealias MoleculeInfo = (identifier: String, class: AnyClass, molecule: MoleculeModelProtocol) + } diff --git a/MVMCoreUI/BaseControllers/ViewController.swift b/MVMCoreUI/BaseControllers/ViewController.swift index 0fc27c5a..e9bfbb6e 100644 --- a/MVMCoreUI/BaseControllers/ViewController.swift +++ b/MVMCoreUI/BaseControllers/ViewController.swift @@ -62,6 +62,8 @@ import Combine // MARK: - Response handling //-------------------------------------------------- + typealias PageUpdateBatch = (pageUpdates: [String : Any]?, pageModel: PageModelProtocol?, moduleUpdates: [String : Any]?) + open func observeForResponseJSONUpdates() { guard observingForResponses == nil, (pagesToListenFor()?.count ?? 0 > 0 || modulesToListenFor()?.count ?? 0 > 0) @@ -69,24 +71,33 @@ import Combine observingForResponses = NotificationCenter.default.publisher(for: NSNotification.Name(rawValue: NotificationResponseLoaded)) .receive(on: self.pageUpdateQueue) // Background serial queue. + // Receive new data for this page and filter out any that do not apply. .compactMap { [weak self] notification in self?.pullUpdates(from: notification) ?? nil } // Merge all page and module updates into one update event. - .scan((nil, nil, nil)) { accumulator, next in + .scan(((nil,nil,nil), 0)) { (accumulator: (PageUpdateBatch, Int), next: PageUpdateBatch ) in + let (accumulatedUpdates, batchCount) = accumulator // Always take the latest page and the latest modules with same key. - return (next.0 ?? accumulator.0, next.1 ?? accumulator.1, next.2?.mergingRight(accumulator.2 ?? [:])) + let updatedBatch: PageUpdateBatch = ( + next.pageUpdates ?? accumulatedUpdates.pageUpdates, + next.pageModel ?? accumulatedUpdates.pageModel, + next.moduleUpdates?.mergingRight(accumulatedUpdates.moduleUpdates ?? [:]) + ) + let updates = (updatedBatch, batchCount + 1) + return updates } // Delay allowing the previous model update to settle before triggering a re-render. - .throttle(for: .seconds(0.25), scheduler: RunLoop.main, latest: true) - .sink { [weak self] (pageUpdates: [String : Any]?, pageModel: PageModelProtocol?, moduleUpdates: [String : Any]?) in + .throttle(for: .seconds(0.5), scheduler: RunLoop.main, latest: true) + .sink { [weak self] (pendingUpdates: PageUpdateBatch, batchCount: Int) in guard let self = self else { return } + let (pageUpdates, pageModel, moduleUpdates) = pendingUpdates if let pageUpdates, pageModel != nil { self.loadObject?.pageJSON = pageUpdates } let mergedModuleUpdates = (loadObject?.modulesJSON ?? [:]).mergingLeft(moduleUpdates ?? [:]) self.loadObject?.modulesJSON = mergedModuleUpdates - self.debugLog("Applying async update page model \(pageModel.debugDescription) and modules \(mergedModuleUpdates.keys) to page.") + self.debugLog("Applying async update page model \(pageModel.debugDescription) and modules \(mergedModuleUpdates.keys) to page. (Batches: \(batchCount))") self.handleNewData(pageModel) } } @@ -97,7 +108,7 @@ import Combine self.observingForResponses = nil } - func pullUpdates(from notification: Notification) -> (pageUpdates: [String : Any]?, pageModel: PageModelProtocol?, moduleUpdates: [String : Any]?)? { + func pullUpdates(from notification: Notification) -> PageUpdateBatch? { // Get the page data. let pageUpdates = extractInterestedPageType(from: notification.userInfo?.optionalDictionaryForKey(KeyPageMap) ?? [:]) // Convert the page data into a new model. diff --git a/MVMCoreUI/Behaviors/AddRemoveMoleculeBehavior.swift b/MVMCoreUI/Behaviors/AddRemoveMoleculeBehavior.swift index 0288ee1b..219ec8ff 100644 --- a/MVMCoreUI/Behaviors/AddRemoveMoleculeBehavior.swift +++ b/MVMCoreUI/Behaviors/AddRemoveMoleculeBehavior.swift @@ -79,9 +79,8 @@ public class AddRemoveMoleculesBehavior: PageCustomActionHandlerBehavior, PageMo public func willRender(rootMolecules: [MoleculeModelProtocol], _ delegateObject: MVMCoreUIDelegateObject?) { guard let list = delegate?.moleculeListDelegate else { return } for case let model as (MoleculeModelProtocol & ListItemModelProtocol & AddMolecules) in rootMolecules { - if let moleculesToAdd = model.getRecursiveMoleculesToAdd(), - let indexPath = list.getAdjustedIndexPath(for: model, position: moleculesToAdd.1) { - list.addMolecules(moleculesToAdd.0, indexPath: indexPath, animation: nil) + if let moleculesToAdd = model.getRecursiveMoleculesToAdd() { + list.addMolecules(moleculesToAdd.0, after: model, animation: nil) } } } @@ -98,24 +97,26 @@ public class AddRemoveMoleculesBehavior: PageCustomActionHandlerBehavior, PageMo let moleculesToAdd = sourceModel.getRecursiveMoleculesToAdd(), let indexPath = list.getAdjustedIndexPath(for: sourceModel, position: moleculesToAdd.1) else { break } await MainActor.run { - list.addMolecules(moleculesToAdd.0, indexPath: indexPath, animation: model.animation) + // TODO: Pipe the position. + list.addMolecules(moleculesToAdd.0, after: sourceModel, animation: model.animation) } case let model as RemoveMoleculesActionModel: guard let list = delegate?.moleculeListDelegate, let sourceModel = MVMCoreUIActionHandler.getSourceModel(from: additionalData) as? (ListItemModelProtocol & MoleculeModelProtocol & RemoveMolecules), let moleculesToRemove = sourceModel.getRecursiveMoleculesToRemove() else { break } await MainActor.run { - list.removeMolecules(moleculesToRemove, animation: model.animation) + list.removeMolecules(moleculesToRemove, animation: model.animation) } case let model as SwapMoleculesActionModel: guard let list = delegate?.moleculeListDelegate, let sourceModel = MVMCoreUIActionHandler.getSourceModel(from: additionalData) as? (ListItemModelProtocol & MoleculeModelProtocol & RemoveMolecules & AddMolecules), let moleculesToRemove = sourceModel.getRecursiveMoleculesToRemove(), - let moleculesToAdd = sourceModel.getRecursiveMoleculesToAdd(), - let indexPath = list.getAdjustedIndexPath(for: sourceModel, position: moleculesToAdd.1) + let moleculesToAdd = sourceModel.getRecursiveMoleculesToAdd() + //let indexPath = list.getAdjustedIndexPath(for: sourceModel, position: moleculesToAdd.1) else { break } await MainActor.run { - list.swapMolecules(moleculesToRemove, with: moleculesToAdd.0, at: indexPath, animation: model.animation) + // TODO: Take into account position. + list.swapMolecules(moleculesToRemove, with: moleculesToAdd.0, after: sourceModel, animation: model.animation) } default: break