From 047039fdc6c682d69281145e196e69aa9ff565ad Mon Sep 17 00:00:00 2001 From: "Hedden, Kyle Matthew" Date: Fri, 20 Sep 2024 20:26:38 -0400 Subject: [PATCH] Digital PCT265 defect MVAPCT-272: Create a list item molecule cache for adding additional list items and quick lookups to the model tree. Add the cell reuse ID to MoleculeInfo identify for quick identification of structural changes. --- .../Protocols/MoleculeListProtocol.swift | 11 +- .../Templates/MoleculeListTemplate.swift | 145 +++++++++++------- 2 files changed, 99 insertions(+), 57 deletions(-) diff --git a/MVMCoreUI/Atomic/Protocols/MoleculeListProtocol.swift b/MVMCoreUI/Atomic/Protocols/MoleculeListProtocol.swift index 226c076f..c5d21da6 100644 --- a/MVMCoreUI/Atomic/Protocols/MoleculeListProtocol.swift +++ b/MVMCoreUI/Atomic/Protocols/MoleculeListProtocol.swift @@ -11,14 +11,17 @@ public protocol MoleculeListProtocol { /// Asks the delegate for the index of molecule. func getIndexPath(for molecule: ListItemModelProtocol & MoleculeModelProtocol) -> IndexPath? - /// Asks the delegate to add molecules. + /// Asks the delegate to add molecules. Prefer using the molecule relatoinal method over this one to avoid misplacing things mid-transition. func addMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], indexPath: IndexPath, animation: UITableView.RowAnimation?) - /// + /// Asks the delegate to add molecules in relation to another molecule. This is the preferred method for relativity. func addMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], position: AddMoleculesPosition, 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 molecules. Never ask to remove a molecule at an index. + + Note: Avoid doing this to prevent accidental deletion mid transition. Prefer the declarative approach of deleting a paricular molecule with removeMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol]. + */ + 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?) diff --git a/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift b/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift index 14c42f43..499cc88c 100644 --- a/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift +++ b/MVMCoreUI/Atomic/Templates/MoleculeListTemplate.swift @@ -12,10 +12,11 @@ public struct MoleculeInfo: Hashable, CustomDebugStringConvertible { public func hash(into hasher: inout Hasher) { hasher.combine(moleculeId) + hasher.combine(cellReuseId) } public static func == (lhs: MoleculeInfo, rhs: MoleculeInfo) -> Bool { - lhs.moleculeId == rhs.moleculeId + lhs.moleculeId == rhs.moleculeId && lhs.cellReuseId == rhs.cellReuseId } public var moleculeId: String @@ -32,6 +33,10 @@ public struct MoleculeInfo: Hashable, CustomDebugStringConvertible { self.cellReuseId = moleculeClass.nameForReuse(with: listItemModel, delegateObject) ?? listItemModel.moleculeName } + public func register(with tableView: UITableView) { + tableView.register(cellType, forCellReuseIdentifier: cellReuseId) + } + public var debugDescription: String { return "\(Self.self) \(moleculeId)" } @@ -39,13 +44,9 @@ public struct MoleculeInfo: Hashable, CustomDebugStringConvertible { extension Array where Element == MoleculeInfo { - func filter(byMolecules molecules: [MoleculeModelProtocol]) -> [MoleculeInfo] { - filter { listItemRef in molecules.contains { $0.id == listItemRef.moleculeId } } - } - @discardableResult func registerTypes(with tableView: UITableView) -> [MoleculeInfo] { - forEach { tableView.register($0.cellType, forCellReuseIdentifier: $0.cellReuseId) } + forEach { $0.register(with: tableView) } return self } } @@ -92,6 +93,8 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol // MARK: - Methods //-------------------------------------------------- + @MainActor public var moleculeModelCache = [String: (ListItemModelProtocol & MoleculeModelProtocol)]() + public var dataSource: UITableViewDiffableDataSource! open override func createTableView() -> TableView { @@ -99,9 +102,9 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol dataSource = UITableViewDiffableDataSource(tableView: tableView) { [weak self] tableView, indexPath, moleculeInfo in guard let self = self, let cell = tableView.dequeueReusableCell(withIdentifier: moleculeInfo.cellReuseId), - let moleculeModel = molecule(for: moleculeInfo.moleculeId) + let moleculeModel = moleculeModelCache[moleculeInfo.moleculeId] else { return UITableViewCell() } - cell.isHidden = (moleculeModel as? GoneableProtocol)?.gone == true + cell.isHidden = moleculeModel.gone (cell as? MoleculeViewProtocol)?.reset() (cell as? MoleculeListCellProtocol)?.setLines(with: templateModel?.line, delegateObject: delegateObjectIVar, additionalData: nil, indexPath: indexPath) if let moleculeView = cell as? MoleculeViewProtocol { @@ -116,13 +119,6 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol return tableView } - // TODO: This assumes a molecule is always present in the model tree. What if its in another data store? Also we should attempt O(1) for being in the render phase. - func molecule(for id: String) -> MoleculeModelProtocol? { - templateModel?.findFirstMolecule() { molecule in - molecule.id == id - } - } - open override func parsePageJSON(loadObject: MVMCoreLoadObject) throws -> PageModelProtocol { return try parseTemplate(loadObject: loadObject) } @@ -167,7 +163,7 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol if pageModel != nil { setup() - registerWithTable() + registerWithTable() // In this template, the cells are registered in setup. However, in subclasses we might still be registering cells in a secondary table outside of this template's content. } } @@ -179,7 +175,14 @@ 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 molecule = molecule as? (ListItemModelProtocol & MoleculeModelProtocol), moleculeModelCache.keys.contains(molecule.id) { + moleculeModelCache[molecule.id] = molecule + } + }) + + // For updating individual specfied molecules. (Not a full table reload done in the base class.) newData(for: molecules) } @@ -211,13 +214,14 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol open func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { guard let moleculeId = dataSource.itemIdentifier(for: indexPath)?.moleculeId, - let moleculeModel = molecule(for: moleculeId) else { return 0 } - return (moleculeModel as? GoneableProtocol)?.gone == true ? 0 : UITableView.automaticDimension + let moleculeModel = moleculeModelCache[moleculeId] else { return 0 } + return moleculeModel.gone ? 0 : UITableView.automaticDimension } open func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard let moleculeInfo = dataSource.itemIdentifier(for: indexPath), - let moleculeModel = molecule(for: moleculeInfo.moleculeId), + let moleculeModel = moleculeModelCache[moleculeInfo.moleculeId], + !moleculeModel.gone, let estimatedHeight = moleculeInfo.cellType.estimatedHeight(with: moleculeModel, delegateObject() as? MVMCoreUIDelegateObject) else { return 0 } @@ -299,31 +303,57 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol } var snapshot = dataSource.snapshot() - let updatedListItems: [MoleculeInfo] = snapshot.itemIdentifiers.filter { listItemRef -> Bool in - guard let model = molecule(for: listItemRef.moleculeId) else { return false } + + // Find all the listItems that have structures and table cell references. + let replacementPairs: [(MoleculeInfo, MoleculeInfo)] = snapshot.itemIdentifiers.compactMap { listItemRef in + guard let model = moleculeModelCache[listItemRef.moleculeId], + let updatedListItemRef = MoleculeInfo(listItemModel: model, delegateObject: delegateObjectIVar) + else { return nil } + if updatedListItemRef.moleculeId == listItemRef.moleculeId && updatedListItemRef != listItemRef { + updatedListItemRef.register(with: tableView) + return (listItemRef, updatedListItemRef) + } else { + return nil + } + } + + // Swap them out for new list items. + for (originalRef, replacementRef) in replacementPairs { + snapshot.insertItems([replacementRef], afterItem: originalRef) + snapshot.deleteItems([originalRef]) + } + + // Out of the remaining items, check which contain molecules that have been updated. + let updatedListItems = snapshot.itemIdentifiers.filter({ listItemRef in + // Ignore the brand new. + guard !replacementPairs.map({ $0.1 }).contains(listItemRef) else { return false } + guard let model = moleculeModelCache[listItemRef.moleculeId] else { return false } return model.findFirstMolecule(by: { existingMolecule in molecules.contains { moleculeModel in existingMolecule.moleculeName == moleculeModel.moleculeName && existingMolecule.id == moleculeModel.id } }) != nil - } + }) + 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) } - // TODO: Thoughts on fading on cell structural replacements? - // dataSource.defaultRowAnimation = .fade - dataSource.apply(snapshot) - // 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) + dataSource.defaultRowAnimation = .fade + dataSource.apply(snapshot) { [self] in + dataSource.defaultRowAnimation = .automatic + + // Refresh the cell. (reload loses cell selection). + if let selectedIndex = tableView.indexPathForSelectedRow { + tableView.selectRow(at: selectedIndex, animated: false, scrollPosition: .none) + } + + // If the height of the cells change, we need to update the constraints. + view.setNeedsUpdateConstraints() } - - // If the height of the cells change, we need to update the constraints. - view.setNeedsUpdateConstraints() } ///Helper functions to update header/footer view @@ -343,14 +373,21 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol //-------------------------------------------------- /// Sets up the header, footer, molecule list and ensures no errors loading all content. - func setup() { - guard let listItems = templateModel?.molecules?.asMoleculeInfoRef(delegateObject: delegateObjectIVar).registerTypes(with: tableView) else { return } + @MainActor func setup() { + guard let listItemModels = templateModel?.molecules else { return } + moleculeModelCache.removeAll() // Refresh the cache for full page reload. + let listItems = register(listItemModels: listItemModels) var initialDataSnapshot = ListDataSnapshot() initialDataSnapshot.appendSections([0]) initialDataSnapshot.appendItems(listItems) dataSource.apply(initialDataSnapshot, animatingDifferences: false) } + func register(listItemModels: [any ListItemModelProtocol & MoleculeModelProtocol]) -> [MoleculeInfo] { + listItemModels.forEach { moleculeModelCache[$0.id] = $0 } + return listItemModels.asMoleculeInfoRef(delegateObject: delegateObjectIVar).registerTypes(with: tableView) + } + /// Adds modules from requiredModules() to the MVMCoreViewControllerMapping.requiredModules map. open func updateRequiredModules() { if let requiredModules = requiredModules(), let pageType = pageType { @@ -373,27 +410,26 @@ open class MoleculeListTemplate: ThreeLayerTableViewController, TemplateProtocol return modules } - - /// Checks if the two molecules are equal - private func equal(moleculeA: MoleculeModelProtocol, moleculeB: MoleculeModelProtocol) -> Bool { - moleculeA.id == moleculeB.id - } } 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) -// } -// -// guard let animation = animation, -// indexPaths.count > 0 else { return } -// tableView?.deleteRows(at: indexPaths, with: animation) -// updateViewConstraints() -// view.setNeedsLayout() -// } + public func removeMolecules(at indexPaths: [IndexPath], animation: UITableView.RowAnimation?) { + var snapshot = dataSource.snapshot() + let rowsToDelete = indexPaths.map { $0.row } + let itemsToDelete = snapshot.itemIdentifiers.enumerated().compactMap { index, itemIdentifier in + return rowsToDelete.contains(index) ? itemIdentifier : nil + } + snapshot.deleteItems(itemsToDelete) + if let animation { + dataSource.defaultRowAnimation = animation + } + dataSource.apply(snapshot) { + self.dataSource.defaultRowAnimation = .automatic + } + updateViewConstraints() + view.setNeedsLayout() + } /// Convenience function that removes the passed molecule public func removeMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], animation: UITableView.RowAnimation?) { @@ -402,11 +438,13 @@ extension MoleculeListTemplate: MoleculeListProtocol { dataSource.apply(snapshot, animatingDifferences: true) { self.updateViewConstraints() self.view.setNeedsLayout() + molecules.forEach { self.moleculeModelCache.removeValue(forKey: $0.id) } } } public func addMolecules(_ molecules: [ListItemModelProtocol & MoleculeModelProtocol], indexPath: IndexPath, animation: UITableView.RowAnimation?) { let additionalMoleculesRef = molecules.asMoleculeInfoRef(delegateObject: delegateObjectIVar).registerTypes(with: tableView) + var snapshot = dataSource.snapshot() debugLog("[ADD] State before: \(snapshot.itemIdentifiers)") if let targetMoleculeRef = snapshot.itemIdentifiers[safe: indexPath.row] { @@ -428,7 +466,7 @@ extension MoleculeListTemplate: MoleculeListProtocol { var snapshot = dataSource.snapshot() debugLog("[ADD] State before: \(snapshot.itemIdentifiers)") - let additionalMoleculesRef = molecules.asMoleculeInfoRef(delegateObject: delegateObjectIVar).registerTypes(with: tableView) + let additionalMoleculesRef = register(listItemModels: molecules) switch position { case .below: snapshot.insertItems(additionalMoleculesRef, afterItem: targetMoleculeRef) @@ -463,7 +501,7 @@ extension MoleculeListTemplate: MoleculeListProtocol { debugLog("[SWAP] State after delete: \(snapshot.itemIdentifiers)") if let molecule, let targetRef = MoleculeInfo(listItemModel: molecule) { - let replacementRefs = replacements.asMoleculeInfoRef(delegateObject: delegateObjectIVar).registerTypes(with: tableView) + let replacementRefs = register(listItemModels: replacements) switch position { case .below: snapshot.insertItems(replacementRefs, afterItem: targetRef) @@ -478,6 +516,7 @@ extension MoleculeListTemplate: MoleculeListProtocol { self.dataSource.defaultRowAnimation = .automatic self.updateViewConstraints() self.view.setNeedsLayout() + molecules.forEach { self.moleculeModelCache.removeValue(forKey: $0.id) } } }