diff options
| author | Jon Petersson <jon.petersson@kvadrat.se> | 2023-03-24 10:56:37 +0100 |
|---|---|---|
| committer | Jon Petersson <jon.petersson@kvadrat.se> | 2023-03-24 13:25:40 +0100 |
| commit | aabccbcd8a3762c4b78e98c699a6c823401e93fc (patch) | |
| tree | 950853da69da6fc5eceb66fc3b8fb33896f99f8c | |
| parent | c570f1bd6341e76f65056bd33ad205844b55bb77 (diff) | |
| download | mullvadvpn-aabccbcd8a3762c4b78e98c699a6c823401e93fc.tar.xz mullvadvpn-aabccbcd8a3762c4b78e98c699a6c823401e93fc.zip | |
Remove custom data source snapshot from device management view
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 10 | ||||
| -rw-r--r-- | ios/MullvadVPN/Classes/DataSourceSnapshot.swift | 541 | ||||
| -rw-r--r-- | ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift | 90 | ||||
| -rw-r--r-- | ios/MullvadVPNTests/DataSourceSnapshotTests.swift | 145 |
4 files changed, 71 insertions, 715 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index cd258fb365..33dc34a1b4 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -74,7 +74,6 @@ 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */; }; 58138E61294871C600684F0C /* DeviceDataThrottling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58138E60294871C600684F0C /* DeviceDataThrottling.swift */; }; 58153071294CBE8B00D1702E /* MullvadREST.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 06799ABC28F98E1D00ACD94E /* MullvadREST.framework */; }; - 5819C2142726CC8D00D6EC38 /* DataSourceSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5819C2132726CC8D00D6EC38 /* DataSourceSnapshotTests.swift */; }; 5819C2172729595500D6EC38 /* SettingsAddDNSEntryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */; }; 5820676426E771DB00655B05 /* TunnelManagerErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820676326E771DB00655B05 /* TunnelManagerErrors.swift */; }; 5820EDA9288FE064006BF4E4 /* DeviceManagementInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5820EDA8288FE064006BF4E4 /* DeviceManagementInteractor.swift */; }; @@ -168,7 +167,6 @@ 587D9676288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */; }; 587DCCEF287D84A500CE821E /* countries.geo.json in Resources */ = {isa = PBXBuildFile; fileRef = 587DCCEE287D84A500CE821E /* countries.geo.json */; }; 587EB66A270EFACB00123C75 /* CharacterSet+IPAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */; }; - 587EB67027143B6500123C75 /* DataSourceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */; }; 587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB671271451E300123C75 /* PreferencesViewModel.swift */; }; 587EB6742714520600123C75 /* PreferencesDataSourceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB6732714520600123C75 /* PreferencesDataSourceDelegate.swift */; }; 5883A09E266A5AF7003EFFCB /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 587B7543266922BF00DEF7E9 /* Localizable.strings */; }; @@ -229,7 +227,6 @@ 58B43C1925F77DB60002C8C3 /* TunnelControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B43C1825F77DB60002C8C3 /* TunnelControlView.swift */; }; 58B8644529C7971B005E107C /* AccountTokenInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582AE30F2440A6CA00E6733A /* AccountTokenInput.swift */; }; 58B8644629C7972F005E107C /* CustomDateComponentsFormatting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */; }; - 58B8644729C79737005E107C /* DataSourceSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */; }; 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B93A1226C3F13600A55733 /* TunnelState.swift */; }; 58B993B12608A34500BA7811 /* LoginContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B993B02608A34500BA7811 /* LoginContentView.swift */; }; 58B9EB152489139B00095626 /* RESTError+Display.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B9EB142489139B00095626 /* RESTError+Display.swift */; }; @@ -682,7 +679,6 @@ 581943E228F8010400B0CB5E /* Date+LogFormat.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Date+LogFormat.swift"; sourceTree = "<group>"; }; 581943E328F8010400B0CB5E /* CustomFormatLogHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomFormatLogHandler.swift; sourceTree = "<group>"; }; 581943E428F8010400B0CB5E /* OSLogHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OSLogHandler.swift; sourceTree = "<group>"; }; - 5819C2132726CC8D00D6EC38 /* DataSourceSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceSnapshotTests.swift; sourceTree = "<group>"; }; 5819C2162729595500D6EC38 /* SettingsAddDNSEntryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAddDNSEntryCell.swift; sourceTree = "<group>"; }; 5820675A26E6576800655B05 /* RelayCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayCache.swift; sourceTree = "<group>"; }; 5820675D26E6839900655B05 /* PresentAlertOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentAlertOperation.swift; sourceTree = "<group>"; }; @@ -791,7 +787,6 @@ 587D9675288989DB00CD8F1C /* NSLayoutConstraint+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSLayoutConstraint+Helpers.swift"; sourceTree = "<group>"; }; 587DCCEE287D84A500CE821E /* countries.geo.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = countries.geo.json; sourceTree = "<group>"; }; 587EB669270EFACB00123C75 /* CharacterSet+IPAddress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CharacterSet+IPAddress.swift"; sourceTree = "<group>"; }; - 587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSourceSnapshot.swift; sourceTree = "<group>"; }; 587EB671271451E300123C75 /* PreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesViewModel.swift; sourceTree = "<group>"; }; 587EB6732714520600123C75 /* PreferencesDataSourceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesDataSourceDelegate.swift; sourceTree = "<group>"; }; 588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadTunnelConfigurationOperation.swift; sourceTree = "<group>"; }; @@ -1461,7 +1456,6 @@ 58FEEB57260B662E00A621A8 /* AutomaticKeyboardResponder.swift */, 5871FB95254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift */, 5896AE83246D5889005B36CB /* CustomDateComponentsFormatting.swift */, - 587EB66F27143B6500123C75 /* DataSourceSnapshot.swift */, 5872D6E7286304DE00DB5F4E /* TermsOfService.swift */, 587988C628A2A01F00E3DF54 /* AccountDataThrottling.swift */, 58138E60294871C600684F0C /* DeviceDataThrottling.swift */, @@ -1643,7 +1637,6 @@ 58B0A2A4238EE67E00BC001D /* Info.plist */, 584B26F3237434D00073B10E /* RelaySelectorTests.swift */, 5807E2C1243203D000F5FF30 /* StringTests.swift */, - 5819C2132726CC8D00D6EC38 /* DataSourceSnapshotTests.swift */, 582A8A3928BCE19B00D0F9FB /* FixedWidthIntegerArithmeticsTests.swift */, ); path = MullvadVPNTests; @@ -2552,10 +2545,8 @@ files = ( 58B8644529C7971B005E107C /* AccountTokenInput.swift in Sources */, 582AE3122440CA0D00E6733A /* AccountTokenInputTests.swift in Sources */, - 58B8644729C79737005E107C /* DataSourceSnapshot.swift in Sources */, 582A8A3A28BCE19B00D0F9FB /* FixedWidthIntegerArithmeticsTests.swift in Sources */, 5896AE86246D6AD8005B36CB /* CustomDateComponentsFormattingTests.swift in Sources */, - 5819C2142726CC8D00D6EC38 /* DataSourceSnapshotTests.swift in Sources */, 58B8644629C7972F005E107C /* CustomDateComponentsFormatting.swift in Sources */, 5807E2C2243203D000F5FF30 /* StringTests.swift in Sources */, 5807E2C3243203E700F5FF30 /* String+Split.swift in Sources */, @@ -2740,7 +2731,6 @@ 5811DE50239014550011EB53 /* NEVPNStatus+Debug.swift in Sources */, 58C3A4B222456F1B00340BDB /* AccountInputGroupView.swift in Sources */, 58ACF64B26553C3F00ACE4B7 /* SettingsSwitchCell.swift in Sources */, - 587EB67027143B6500123C75 /* DataSourceSnapshot.swift in Sources */, 583FE00E29C0D586006E85F9 /* OutOfTimeCoordinator.swift in Sources */, 58A8EE5E2976DB00009C0F8D /* StorePaymentManagerError+Display.swift in Sources */, 580F8B8328197881002E0998 /* TunnelSettingsV2.swift in Sources */, diff --git a/ios/MullvadVPN/Classes/DataSourceSnapshot.swift b/ios/MullvadVPN/Classes/DataSourceSnapshot.swift deleted file mode 100644 index b68935000c..0000000000 --- a/ios/MullvadVPN/Classes/DataSourceSnapshot.swift +++ /dev/null @@ -1,541 +0,0 @@ -// -// DataSourceSnapshot.swift -// MullvadVPN -// -// Created by pronebird on 11/10/2021. -// Copyright © 2021 Mullvad VPN AB. All rights reserved. -// - -import Foundation -import UIKit - -/// `NSDiffableDataSourceSnapshot` replica. -struct DataSourceSnapshot<Section: Hashable, Item: Hashable> { - /// Ordered set of section identifiers. - private var orderedSections = NSMutableOrderedSet() - - /// Ordered set of item identifiers. - private var orderedItems = NSMutableOrderedSet() - - /// Item identifier ranges by section. - private var sectionToItemMapping = [Range<Int>]() - - /// Items to reload. - private var itemsToReload = NSMutableOrderedSet() - - /// Items to reconfigure. - private var itemsToReconfigure = NSMutableOrderedSet() - - /// Ordered array of section identifiers. - var sectionIdentifiers: [Section] { - return orderedSections.array as! [Section] - } - - /// Ordered array of item identifiers. - var itemIdentifiers: [Item] { - return orderedItems.array as! [Item] - } - - mutating func appendItems(_ itemsToAppend: [Item], in section: Section) { - assert(orderedSections.contains(section)) - - let sectionIndex = indexOfSection(section)! - let uniqueItemsToAppend = NSOrderedSet(array: itemsToAppend) - let itemRange = sectionToItemMapping[sectionIndex] - - let oldEndIndex = itemRange.endIndex - let newEndIndex = itemRange.endIndex.advanced(by: uniqueItemsToAppend.count) - let newItemRange = (itemRange.startIndex ..< newEndIndex) - - sectionToItemMapping[sectionIndex] = newItemRange - orderedItems.insert( - uniqueItemsToAppend.array, - at: IndexSet(integersIn: oldEndIndex ..< newEndIndex) - ) - - offsetItemRange(inSectionsAfter: sectionIndex, by: uniqueItemsToAppend.count) - } - - mutating func appendSections(_ newSections: [Section]) { - let lastSectionRange = sectionToItemMapping.last - let emptyRange = lastSectionRange.flatMap { range in - return (range.upperBound ..< range.upperBound) - } ?? (0 ..< 0) - - let uniqueNewSections = NSOrderedSet(array: newSections) - - for newSection in uniqueNewSections { - orderedSections.add(newSection) - sectionToItemMapping.append(emptyRange) - } - } - - func section(at index: Int) -> Section? { - if index < orderedSections.count { - return orderedSections.object(at: index) as? Section - } else { - return nil - } - } - - func indexOfSection(_ section: Section) -> Int? { - let index = orderedSections.index(of: section) - if index == NSNotFound { - return nil - } else { - return index - } - } - - func numberOfSections() -> Int { - return orderedSections.count - } - - func numberOfItems(in section: Section) -> Int? { - guard let sectionIndex = indexOfSection(section) else { return nil } - - return sectionToItemMapping[sectionIndex].count - } - - func items(in section: Section) -> [Item] { - guard let sectionIndex = indexOfSection(section) else { return [] } - - let range = sectionToItemMapping[sectionIndex] - let indexSet = IndexSet(integersIn: range) - - return orderedItems.objects(at: indexSet) as! [Item] - } - - func itemForIndexPath(_ indexPath: IndexPath) -> Item? { - guard indexPath.section < orderedSections.count else { return nil } - - let itemRange = sectionToItemMapping[indexPath.section] - let itemIndex = itemRange.startIndex + indexPath.row - - if itemRange.contains(itemIndex) { - return orderedItems.object(at: itemIndex) as? Item - } else { - return nil - } - } - - func indexPathForItem(_ item: Item) -> IndexPath? { - let itemIndex = orderedItems.index(of: item) - guard itemIndex != NSNotFound else { return nil } - - guard let sectionIdentifier = section(containingItem: item) else { return nil } - - let sectionIndex = orderedSections.index(of: sectionIdentifier) - guard sectionIndex != NSNotFound else { return nil } - - let range = sectionToItemMapping[sectionIndex] - let rowIndex = itemIndex - range.startIndex - - return IndexPath(row: rowIndex, section: sectionIndex) - } - - func section(containingItem item: Item) -> Section? { - let itemIndex = orderedItems.index(of: item) - guard itemIndex != NSNotFound else { return nil } - - for (sectionIndex, sectionObject) in orderedSections.enumerated() { - let sectionIdentifier = sectionObject as! Section - let range = sectionToItemMapping[sectionIndex] - - if range.contains(itemIndex) { - return sectionIdentifier - } - } - - return nil - } - - mutating func reloadItems(_ items: [Item]) { - itemsToReload.addObjects(from: items) - } - - mutating func reconfigureItems(_ items: [Item]) { - itemsToReconfigure.addObjects(from: items) - } - - private mutating func offsetItemRange(inSectionsAfter sectionIndex: Int, by offset: Int) { - let startIndex = sectionIndex + 1 - let sectionRange = (startIndex ..< orderedSections.count) - - for sectionIndex in sectionRange { - let range = sectionToItemMapping[sectionIndex] - let offsetRange = (range.startIndex + offset ..< range.endIndex + offset) - - sectionToItemMapping[sectionIndex] = offsetRange - } - } -} - -extension DataSourceSnapshot { - enum Change: CustomDebugStringConvertible, Hashable { - case insert(IndexPath) - case delete(IndexPath) - case move(_ source: IndexPath, _ target: IndexPath) - case reload(IndexPath) - case reconfigure(IndexPath) - - var sortOrder: Int { - switch self { - case .delete: - return 0 - case .insert: - return 1 - case .move: - return 2 - case .reload: - return 3 - case .reconfigure: - return 4 - } - } - - var debugDescription: String { - switch self { - case let .insert(indexPath): - return "insert \(indexPath)" - case let .delete(indexPath): - return "delete \(indexPath)" - case let .move(source, target): - return "move from \(source) to \(target)" - case let .reload(indexPath): - return "reload \(indexPath)" - case let .reconfigure(indexPath): - return "reconfigure \(indexPath)" - } - } - - func breakMoveOntoInsertionDeletion() -> [Change] { - if case let .move(fromIndexPath, toIndexPath) = self { - return [.delete(fromIndexPath), .insert(toIndexPath)] - } else { - return [self] - } - } - } - - func difference(_ other: DataSourceSnapshot<Section, Item>) -> DataSnapshotDifference { - var changes = [Change]() - - let oldItems = itemIdentifiers - let newItems = other.itemIdentifiers - - for item in oldItems { - let oldIndexPath = indexPathForItem(item) - let newIndexPath = other.indexPathForItem(item) - - if let oldIndexPath = oldIndexPath, oldIndexPath != newIndexPath { - guard let newIndexPath = newIndexPath else { - changes.append(.delete(oldIndexPath)) - continue - } - - // Guard against recording the `.move` twice when exchanging two adjacent items. - let isSwappingTwoAdjacentItems = changes.contains { otherChange in - if case let .move(fromIndexPath, toIndexPath) = otherChange { - let itemDistance = abs(oldIndexPath.row - fromIndexPath.row) - - return oldIndexPath == toIndexPath && newIndexPath == fromIndexPath && - oldIndexPath.section == newIndexPath.section && - itemDistance == 1 - - } else { - return false - } - } - - if !isSwappingTwoAdjacentItems { - changes.append(.move(oldIndexPath, newIndexPath)) - } - } - } - - for item in newItems { - if let indexPath = other.indexPathForItem(item), !oldItems.contains(item) { - changes.append(.insert(indexPath)) - } - } - - changes = Self.inferMoves(changes: changes) - - for itemObject in other.itemsToReload { - let itemIdentifier = itemObject as! Item - if let indexPath = other.indexPathForItem(itemIdentifier) { - changes.append(.reload(indexPath)) - } - } - - for itemObject in other.itemsToReconfigure { - let itemIdentifier = itemObject as! Item - if let indexPath = other.indexPathForItem(itemIdentifier) { - changes.append(.reconfigure(indexPath)) - } - } - - changes.sort(by: Self.changeSortPredicate) - - return Self.changeSetToDifference(changes) - } - - /// Infer and discard unnecessary moves that occur due to items shifting back or forth based on - /// insertions and deletions of other items. - private static func inferMoves(changes: [Change]) -> [Change] { - var newChanges = [Change]() - - // Expand .move onto .insert + .delete pair and sort changes. - let sortedChangesWithoutMoves = changes - .flatMap { change in - return change.breakMoveOntoInsertionDeletion() - } - .sorted(by: Self.changeSortPredicate) - - for sourceChange in changes { - guard case let .move(sourceIndexPath, targetIndexPath) = sourceChange else { - newChanges.append(sourceChange) - continue - } - - // Replay all changes to compute the item's index path, ignoring the changes - // associated with the current change. - let inferredIndexPath = sortedChangesWithoutMoves - .reduce(into: sourceIndexPath) { inferredIndexPath, otherChange in - switch otherChange { - case let .insert(insertedIndexPath) where insertedIndexPath != targetIndexPath: - if inferredIndexPath.row >= insertedIndexPath.row, - inferredIndexPath.section == insertedIndexPath.section - { - inferredIndexPath.row += 1 - } - - case let .delete(deletedIndexPath) where deletedIndexPath != sourceIndexPath: - if inferredIndexPath.row > deletedIndexPath.row, - inferredIndexPath.section == deletedIndexPath.section - { - inferredIndexPath.row -= 1 - } - - default: - break - } - } - - // Discard the change if the index path, produced after replaying other changes, - // matches the target index path. - if inferredIndexPath != targetIndexPath { - newChanges.append(contentsOf: sourceChange.breakMoveOntoInsertionDeletion()) - } - } - - return newChanges - } - - /// Sort predicate used for sorting a collection of `Change`. - /// - /// Sort order by kind and index path: - /// Deletion: descending - /// Insertion: ascending - /// Reload, reconfigure: ascending - private static func changeSortPredicate(_ lhs: Change, _ rhs: Change) -> Bool { - switch (lhs, rhs) { - case let (.insert(lhsIndexPath), .insert(rhsIndexPath)): - return lhsIndexPath < rhsIndexPath - - case let (.delete(lhsIndexPath), .delete(rhsIndexPath)): - return lhsIndexPath > rhsIndexPath - - case let (.reload(lhsIndexPath), .reload(rhsIndexPath)): - return lhsIndexPath < rhsIndexPath - - case let (.reconfigure(lhsIndexPath), .reconfigure(rhsIndexPath)): - return lhsIndexPath < rhsIndexPath - - case let (lhs, rhs): - return lhs.sortOrder < rhs.sortOrder - } - } - - private static func changeSetToDifference(_ changes: [Change]) -> DataSnapshotDifference { - var indexPathsToInsert = [IndexPath]() - var indexPathsToDelete = [IndexPath]() - var indexPathsToReload = [IndexPath]() - var indexPathsToReconfigure = [IndexPath]() - - for change in changes { - switch change { - case let .insert(indexPath): - indexPathsToInsert.append(indexPath) - - case let .delete(indexPath): - indexPathsToDelete.append(indexPath) - - case .move: - // Moves are broken down onto insert and delete changes at this point. - break - - case let .reload(indexPath): - indexPathsToReload.append(indexPath) - - case let .reconfigure(indexPath): - indexPathsToReconfigure.append(indexPath) - } - } - - return DataSnapshotDifference( - indexPathsToInsert: indexPathsToInsert, - indexPathsToDelete: indexPathsToDelete, - indexPathsToReload: indexPathsToReload, - indexPathsToReconfigure: indexPathsToReconfigure - ) - } -} - -struct StackViewApplyDataSnapshotConfiguration { - var animationDuration: TimeInterval = 0.25 - var animationOptions: UIView.AnimationOptions = [.curveEaseInOut] - var makeView: (IndexPath) -> UIView -} - -struct DataSnapshotDifference: CustomDebugStringConvertible { - var indexPathsToInsert = [IndexPath]() - var indexPathsToDelete = [IndexPath]() - var indexPathsToReload = [IndexPath]() - var indexPathsToReconfigure = [IndexPath]() - - var debugDescription: String { - var s = "DataSnapshotDifference {\n" - - s += " insert: \n" - for indexPath in indexPathsToInsert { - s += " \(indexPath),\n" - } - - s += " delete: \n" - for indexPath in indexPathsToDelete { - s += " \(indexPath),\n" - } - - s += " reload: \n" - for indexPath in indexPathsToReload { - s += " \(indexPath),\n" - } - - s += " reconfigure: \n" - for indexPath in indexPathsToReconfigure { - s += " \(indexPath),\n" - } - - s += "}" - - return s - } - - func apply( - to tableView: UITableView, - animateDifferences: Bool, - completion: ((Bool) -> Void)? = nil - ) { - let animation: UITableView.RowAnimation = animateDifferences ? .automatic : .none - - tableView.performBatchUpdates({ - if !indexPathsToDelete.isEmpty { - tableView.deleteRows(at: indexPathsToDelete, with: animation) - } - - if !indexPathsToInsert.isEmpty { - tableView.insertRows(at: indexPathsToInsert, with: animation) - } - - if !indexPathsToReload.isEmpty { - tableView.reloadRows(at: indexPathsToReload, with: animation) - } - - if !indexPathsToReconfigure.isEmpty { - if #available(iOS 15.0, *) { - tableView.reconfigureRows(at: indexPathsToReconfigure) - } else { - tableView.reloadRows(at: indexPathsToReconfigure, with: .none) - } - } - }, completion: completion) - } - - func apply( - to stackView: UIStackView, - configuration: StackViewApplyDataSnapshotConfiguration, - animateDifferences: Bool, - completion: ((Bool) -> Void)? = nil - ) { - let viewsToRemove = indexPathsToDelete.map { indexPath in - return stackView.arrangedSubviews[indexPath.row] - } - - let viewsToAdd = indexPathsToInsert.map { indexPath -> UIView in - let view = configuration.makeView(indexPath) - - view.isHidden = true - view.alpha = 0 - - var viewIndex = indexPath.row - - // Adjust insertion index since views are not removed from stack view during animation. - for view in stackView.arrangedSubviews[..<indexPath.row] { - if viewsToRemove.contains(view) { - viewIndex += 1 - } - } - - stackView.insertArrangedSubview(view, at: viewIndex) - - return view - } - - // Layout inserted subviews before running animations to achieve a folding effect. - if animateDifferences { - UIView.performWithoutAnimation { - stackView.layoutIfNeeded() - } - } - - let showHideViews = { - for view in viewsToRemove { - view.alpha = 0 - view.isHidden = true - } - - for view in viewsToAdd { - view.alpha = 1 - view.isHidden = false - } - } - - let removeViews = { - for view in viewsToRemove { - view.removeFromSuperview() - } - } - - if animateDifferences { - UIView.animate( - withDuration: configuration.animationDuration, - delay: 0, - options: configuration.animationOptions, - animations: { - showHideViews() - stackView.layoutIfNeeded() - }, - completion: { isComplete in - removeViews() - completion?(isComplete) - } - ) - } else { - showHideViews() - removeViews() - completion?(true) - } - } -} diff --git a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift index 141ec9e007..d0d0541af5 100644 --- a/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift +++ b/ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift @@ -101,7 +101,7 @@ class DeviceManagementContentView: UIView { var handleDeviceDeletion: ((DeviceViewModel, @escaping () -> Void) -> Void)? - private var currentSnapshot = DataSourceSnapshot<String, String>() + private var currentDeviceModels = [DeviceViewModel]() var canContinue = false { didSet { @@ -186,32 +186,84 @@ class DeviceManagementContentView: UIView { } func setDeviceViewModels(_ newModels: [DeviceViewModel], animated: Bool) { - var newSnapshot = DataSourceSnapshot<String, String>() - newSnapshot.appendSections([""]) - newSnapshot.appendItems(newModels.map { $0.id }, in: "") + let difference = newModels.difference(from: currentDeviceModels) { newModel, model in + return newModel.id == model.id + } + + currentDeviceModels = newModels + + var viewsToAdd: [(view: UIView, offset: Int)] = [] + var viewsToRemove: [UIView] = [] - let diff = currentSnapshot.difference(newSnapshot) - currentSnapshot = newSnapshot + difference.forEach { change in + switch change { + case let .insert(offset, model, _): + let view = DeviceRowView(viewModel: model) - let applyConfiguration = StackViewApplyDataSnapshotConfiguration { indexPath in - let viewModel = newModels[indexPath.row] - let view = DeviceRowView(viewModel: viewModel) - view.deleteHandler = { [weak self] view in - view.showsActivityIndicator = true + view.isHidden = true + view.alpha = 0 - self?.handleDeviceDeletion?(view.viewModel) { - view.showsActivityIndicator = false + view.deleteHandler = { [weak self] _ in + view.showsActivityIndicator = true + + self?.handleDeviceDeletion?(view.viewModel) { + view.showsActivityIndicator = false + } } + + viewsToAdd.append((view, offset)) + + case let .remove(offset, _, _): + viewsToRemove.append(deviceStackView.arrangedSubviews[offset]) } + } - return view + viewsToAdd.forEach { item in + deviceStackView.insertArrangedSubview(item.view, at: item.offset) } - diff.apply( - to: deviceStackView, - configuration: applyConfiguration, - animateDifferences: animated - ) + // Layout inserted subviews before running animations to achieve a folding effect. + if animated { + UIView.performWithoutAnimation { + deviceStackView.layoutIfNeeded() + } + } + + let showHideViews = { + viewsToRemove.forEach { view in + view.alpha = 0 + view.isHidden = true + } + + viewsToAdd.forEach { item in + item.view.alpha = 1 + item.view.isHidden = false + } + } + + let removeViews = { + viewsToRemove.forEach { view in + view.removeFromSuperview() + } + } + + if animated { + UIView.animate( + withDuration: 0.25, + delay: 0, + options: [.curveEaseInOut], + animations: { [weak self] in + showHideViews() + self?.deviceStackView.layoutIfNeeded() + }, + completion: { isComplete in + removeViews() + } + ) + } else { + showHideViews() + removeViews() + } } private func updateView() { diff --git a/ios/MullvadVPNTests/DataSourceSnapshotTests.swift b/ios/MullvadVPNTests/DataSourceSnapshotTests.swift deleted file mode 100644 index 4587df4a75..0000000000 --- a/ios/MullvadVPNTests/DataSourceSnapshotTests.swift +++ /dev/null @@ -1,145 +0,0 @@ -// -// DataSourceSnapshotTests.swift -// MullvadVPNTests -// -// Created by pronebird on 25/10/2021. -// Copyright © 2021 Mullvad VPN AB. All rights reserved. -// - -import XCTest - -class DataSourceSnapshotTests: XCTestCase { - func testInsertingItem() throws { - var a = DataSourceSnapshot<String, Int>() - var b = DataSourceSnapshot<String, Int>() - - a.appendSections(["First"]) - b.appendSections(["First"]) - - a.appendItems([1, 3], in: "First") - b.appendItems([1, 2, 3], in: "First") - - let diff = a.difference(b) - - XCTAssertEqual(diff.indexPathsToDelete, []) - XCTAssertEqual(diff.indexPathsToInsert, [IndexPath(row: 1, section: 0)]) - } - - func testRemovingItem() throws { - var a = DataSourceSnapshot<String, Int>() - var b = DataSourceSnapshot<String, Int>() - - a.appendSections(["First"]) - b.appendSections(["First"]) - - a.appendItems([1, 2, 3], in: "First") - b.appendItems([1, 3], in: "First") - - let diff = a.difference(b) - - XCTAssertEqual(diff.indexPathsToDelete, [IndexPath(row: 1, section: 0)]) - XCTAssertEqual(diff.indexPathsToInsert, []) - } - - func testMovingItemWithinSection() throws { - var a = DataSourceSnapshot<String, Int>() - var b = DataSourceSnapshot<String, Int>() - - a.appendSections(["First"]) - b.appendSections(["First"]) - - a.appendItems([1, 2, 3], in: "First") - b.appendItems([2, 1, 3], in: "First") - - let diff = a.difference(b) - - XCTAssertEqual(diff.indexPathsToDelete, [IndexPath(row: 0, section: 0)]) - XCTAssertEqual(diff.indexPathsToInsert, [IndexPath(row: 1, section: 0)]) - } - - func testMovingItemBetweenSections() throws { - var a = DataSourceSnapshot<String, Int>() - var b = DataSourceSnapshot<String, Int>() - - a.appendSections(["First", "Second"]) - b.appendSections(["First", "Second"]) - - a.appendItems([1, 2, 3, 4], in: "First") - a.appendItems([5, 6, 7, 8], in: "Second") - - b.appendItems([5, 1, 2, 8, 4], in: "First") - b.appendItems([6, 3, 7], in: "Second") - - let diff = a.difference(b) - - XCTAssertEqual(diff.indexPathsToDelete, [ - IndexPath(row: 3, section: 1), - IndexPath(row: 0, section: 1), - IndexPath(row: 2, section: 0), - ]) - - XCTAssertEqual(diff.indexPathsToInsert, [ - IndexPath(row: 0, section: 0), - IndexPath(row: 3, section: 0), - IndexPath(row: 1, section: 1), - ]) - } - - func testSwappingItems() throws { - var a = DataSourceSnapshot<String, Int>() - var b = DataSourceSnapshot<String, Int>() - - a.appendSections(["First"]) - b.appendSections(["First"]) - - a.appendItems([1, 2, 3], in: "First") - b.appendItems([3, 2, 1], in: "First") - - let diff = a.difference(b) - - XCTAssertEqual(diff.indexPathsToDelete, [ - IndexPath(row: 2, section: 0), - IndexPath(row: 0, section: 0), - ]) - - XCTAssertEqual(diff.indexPathsToInsert, [ - IndexPath(row: 0, section: 0), - IndexPath(row: 2, section: 0), - ]) - } - - func testShiftingItems() throws { - var a = DataSourceSnapshot<String, Int>() - var b = DataSourceSnapshot<String, Int>() - - a.appendSections(["First"]) - b.appendSections(["First"]) - - a.appendItems([1, 2, 3, 4], in: "First") - b.appendItems([1, 3, 4, 5], in: "First") - - let diff = a.difference(b) - - XCTAssertEqual(diff.indexPathsToDelete, [IndexPath(row: 1, section: 0)]) - XCTAssertEqual(diff.indexPathsToInsert, [IndexPath(row: 3, section: 0)]) - } - - func testReloadingAndReconfiguringItems() throws { - var a = DataSourceSnapshot<String, Int>() - var b = DataSourceSnapshot<String, Int>() - - a.appendSections(["First"]) - b.appendSections(["First"]) - - a.appendItems([1, 2], in: "First") - b.appendItems([1, 2], in: "First") - - b.reloadItems([1]) - b.reconfigureItems([2]) - - let diff = a.difference(b) - - XCTAssertEqual(diff.indexPathsToReload, [IndexPath(row: 0, section: 0)]) - XCTAssertEqual(diff.indexPathsToReconfigure, [IndexPath(row: 1, section: 0)]) - } -} |
