summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2023-03-24 13:26:38 +0100
committerAndrej Mihajlov <and@mullvad.net>2023-03-24 13:26:38 +0100
commit1101cb548454501dc520ee64e71b80c6b57485cf (patch)
tree950853da69da6fc5eceb66fc3b8fb33896f99f8c
parentc570f1bd6341e76f65056bd33ad205844b55bb77 (diff)
parentaabccbcd8a3762c4b78e98c699a6c823401e93fc (diff)
downloadmullvadvpn-1101cb548454501dc520ee64e71b80c6b57485cf.tar.xz
mullvadvpn-1101cb548454501dc520ee64e71b80c6b57485cf.zip
Merge branch 'remove-datasourcesnapshot-from-device-view-ios-56'
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj10
-rw-r--r--ios/MullvadVPN/Classes/DataSourceSnapshot.swift541
-rw-r--r--ios/MullvadVPN/View controllers/DeviceList/DeviceManagementContentView.swift90
-rw-r--r--ios/MullvadVPNTests/DataSourceSnapshotTests.swift145
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)])
- }
-}