|
| 1 | +import Foundation |
| 2 | + |
| 3 | +/// Represents an atomic batch of changes made to a sectioned collection. |
| 4 | +public struct SectionedChangeset { |
| 5 | + /// Represents a mutated section for either or both its collection of items |
| 6 | + /// and its metadata. |
| 7 | + public struct MutatedSection { |
| 8 | + /// The offset of the section in the previous version of the sectioned |
| 9 | + /// collection. |
| 10 | + public let source: Int |
| 11 | + |
| 12 | + /// The offset of the section in the current version of the sectioned |
| 13 | + /// collection. |
| 14 | + public let destination: Int |
| 15 | + |
| 16 | + /// Represents changes within the section. |
| 17 | + /// |
| 18 | + /// If the changeset specifies no change, it implies that only the |
| 19 | + /// section metadata has been changed. |
| 20 | + public let changeset: Changeset |
| 21 | + |
| 22 | + public init(source: Int, destination: Int, changeset: Changeset) { |
| 23 | + self.source = source |
| 24 | + self.destination = destination |
| 25 | + self.changeset = changeset |
| 26 | + } |
| 27 | + } |
| 28 | + |
| 29 | + /// The changes of sections. |
| 30 | + /// |
| 31 | + /// - precondition: Offsets in `sections.mutations` and `sections.moves` must have a |
| 32 | + /// corresponding entry in `mutatedSections` if they represent a |
| 33 | + /// mutation. |
| 34 | + public var sections = Changeset() |
| 35 | + |
| 36 | + /// The changes of items in the mutated sections. |
| 37 | + /// |
| 38 | + /// - precondition: `mutatedSections` must have an entry for every mutated |
| 39 | + /// sections specified by `sections.mutations` and |
| 40 | + /// `sections.moves`. |
| 41 | + public var mutatedSections: [MutatedSection] = [] |
| 42 | + |
| 43 | + public init(sections: Changeset = Changeset(), mutatedSections: [MutatedSection] = []) { |
| 44 | + (self.sections, self.mutatedSections) = (sections, mutatedSections) |
| 45 | + } |
| 46 | + |
| 47 | + public init<C: Collection>(initial: C) { |
| 48 | + sections.inserts.insert(integersIn: 0 ..< Int(initial.count)) |
| 49 | + } |
| 50 | + |
| 51 | + /// Compute the difference of a collection from its previous version. |
| 52 | + /// |
| 53 | + /// The algorithm works best with collections of uniquely identified values. |
| 54 | + /// |
| 55 | + /// If the multiple elements are bound to the same identifier, the algorithm |
| 56 | + /// would compute shortest moves at best effort, and removals or insertions |
| 57 | + /// depending on the change in the occurences. |
| 58 | + /// |
| 59 | + /// - parameters: |
| 60 | + /// - previous: The previous version of the collection. |
| 61 | + /// - current: The collection. |
| 62 | + /// - sectionIdentifier: A lense to extract the unique identifier of the |
| 63 | + /// given section. |
| 64 | + /// - areMetadataEqual: A predicate to evaluate equality of the two given |
| 65 | + /// sections, which need not take account of the |
| 66 | + /// items. |
| 67 | + /// - items: A lense to extract items from a given section. |
| 68 | + /// - itemIdentifier: A lense to extract the unique identifier of the |
| 69 | + /// given item. |
| 70 | + /// - areItemsEqual: A predicate to evaluate equality of the two given |
| 71 | + /// items. |
| 72 | + /// |
| 73 | + /// - complexity: O(n) time and space. |
| 74 | + public init<Sections: Collection, Items: Collection, SectionIdentifier: Hashable, ItemIdentifier: Hashable>( |
| 75 | + previous: Sections, |
| 76 | + current: Sections, |
| 77 | + sectionIdentifier: (Sections.Element) -> SectionIdentifier, |
| 78 | + areMetadataEqual: (Sections.Element, Sections.Element) -> Bool, |
| 79 | + items: (Sections.Element) -> Items, |
| 80 | + itemIdentifier: (Items.Element) -> ItemIdentifier, |
| 81 | + areItemsEqual: (Items.Element, Items.Element) -> Bool |
| 82 | + ) { |
| 83 | + let metadata = Changeset(previous: previous, current: current, identifier: sectionIdentifier, areEqual: areMetadataEqual) |
| 84 | + |
| 85 | + let moveSourceLookup = Dictionary(uniqueKeysWithValues: metadata.moves.lazy.map { ($0.destination, $0.source) }) |
| 86 | + let mutatedMoveDests = Set(metadata.moves.lazy.filter { $0.isMutated }.map { $0.destination }) |
| 87 | + |
| 88 | + let allInsertions = metadata.inserts.union(IndexSet(moveSourceLookup.keys)) |
| 89 | + let allRemovals = metadata.removals.union(IndexSet(moveSourceLookup.values)) |
| 90 | + |
| 91 | + mutatedSections = Array() |
| 92 | + mutatedSections.reserveCapacity(Int(current.count)) |
| 93 | + |
| 94 | + var moves: [Changeset.Move] = [] |
| 95 | + var mutations = IndexSet() |
| 96 | + |
| 97 | + for (offset, section) in current.enumerated() where !metadata.inserts.contains(offset) { |
| 98 | + let predeletionOffset: Int |
| 99 | + let isMove: Bool |
| 100 | + |
| 101 | + if let moveSource = moveSourceLookup[offset] { |
| 102 | + predeletionOffset = moveSource |
| 103 | + isMove = true |
| 104 | + } else { |
| 105 | + let preinsertionOffset = offset - allInsertions.count(in: 0 ..< offset) |
| 106 | + predeletionOffset = preinsertionOffset + allRemovals.count(in: 0 ... preinsertionOffset) |
| 107 | + isMove = false |
| 108 | + } |
| 109 | + |
| 110 | + let previousIndex = previous.index(previous.startIndex, offsetBy: numericCast(predeletionOffset)) |
| 111 | + let previousItems = items(previous[previousIndex]) |
| 112 | + let currentItems = items(section) |
| 113 | + |
| 114 | + let changeset = Changeset(previous: previousItems, |
| 115 | + current: currentItems, |
| 116 | + identifier: itemIdentifier, |
| 117 | + areEqual: areItemsEqual) |
| 118 | + |
| 119 | + let isMutated = !changeset.hasNoChanges |
| 120 | + || metadata.mutations.contains(predeletionOffset) |
| 121 | + || mutatedMoveDests.contains(offset) |
| 122 | + |
| 123 | + if isMutated { |
| 124 | + let section = SectionedChangeset.MutatedSection(source: predeletionOffset, |
| 125 | + destination: offset, |
| 126 | + changeset: changeset) |
| 127 | + mutatedSections.append(section) |
| 128 | + } |
| 129 | + |
| 130 | + if isMove { |
| 131 | + moves.append(Changeset.Move(source: predeletionOffset, |
| 132 | + destination: offset, |
| 133 | + isMutated: isMutated)) |
| 134 | + } else if isMutated { |
| 135 | + mutations.insert(predeletionOffset) |
| 136 | + } |
| 137 | + } |
| 138 | + |
| 139 | + sections = Changeset(inserts: metadata.inserts, |
| 140 | + removals: metadata.removals, |
| 141 | + mutations: mutations, |
| 142 | + moves: moves) |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +extension SectionedChangeset: CustomDebugStringConvertible { |
| 147 | + public var debugDescription: String { |
| 148 | + let contents: [String] = [ |
| 149 | + sections.debugDescription, |
| 150 | + "- changesets of mutated sections: <<<", |
| 151 | + mutatedSections |
| 152 | + .map { entry in |
| 153 | + return " section \(entry.source) -> \(entry.destination)\n" |
| 154 | + + entry.changeset.debugDescription |
| 155 | + .split(separator: "\n") |
| 156 | + .map { " \($0)" } |
| 157 | + .joined(separator: "\n") |
| 158 | + } |
| 159 | + .joined(separator: "\n"), |
| 160 | + " >>>", |
| 161 | + ] |
| 162 | + return contents.joined(separator: "\n") |
| 163 | + } |
| 164 | +} |
| 165 | + |
| 166 | +extension SectionedChangeset: Equatable { |
| 167 | + public static func == (lhs: SectionedChangeset, rhs: SectionedChangeset) -> Bool { |
| 168 | + func sourceOffsetIncreasingOrder(_ lhs: MutatedSection, _ rhs: MutatedSection) -> Bool { |
| 169 | + return lhs.source < rhs.source |
| 170 | + } |
| 171 | + |
| 172 | + return lhs.sections == rhs.sections |
| 173 | + && lhs.mutatedSections.sorted(by: sourceOffsetIncreasingOrder) == rhs.mutatedSections.sorted(by: sourceOffsetIncreasingOrder) |
| 174 | + } |
| 175 | +} |
| 176 | + |
| 177 | +extension SectionedChangeset.MutatedSection: Equatable { |
| 178 | + public static func == (lhs: SectionedChangeset.MutatedSection, rhs: SectionedChangeset.MutatedSection) -> Bool { |
| 179 | + return lhs.source == rhs.source |
| 180 | + && lhs.destination == rhs.destination |
| 181 | + && lhs.changeset == rhs.changeset |
| 182 | + } |
| 183 | +} |
0 commit comments