diff --git a/LayoutKit.xcodeproj/project.pbxproj b/LayoutKit.xcodeproj/project.pbxproj index 4d3ca593..cd8bacb1 100644 --- a/LayoutKit.xcodeproj/project.pbxproj +++ b/LayoutKit.xcodeproj/project.pbxproj @@ -209,8 +209,6 @@ 44F968181E4263DC00392763 /* TextViewLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F968161E42639500392763 /* TextViewLayoutTests.swift */; }; 44F968191E4263DC00392763 /* TextViewLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F968161E42639500392763 /* TextViewLayoutTests.swift */; }; 44F9681A1E42640400392763 /* TextViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44F968141E425F5D00392763 /* TextViewLayout.swift */; }; - AD2C36441EA5AFB500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2C36421EA5AF9500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift */; }; - ADE5FCC11EA5B5F3006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE5FCBF1EA5B5C8006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift */; }; 75D94A361EA01B6A00A5FD01 /* OverlayLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D94A351EA01B6A00A5FD01 /* OverlayLayout.swift */; }; 75D94A371EA01B7100A5FD01 /* OverlayLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D94A351EA01B6A00A5FD01 /* OverlayLayout.swift */; }; 75D94A381EA01B7200A5FD01 /* OverlayLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D94A351EA01B6A00A5FD01 /* OverlayLayout.swift */; }; @@ -218,6 +216,18 @@ 75D94A3C1EA045F100A5FD01 /* OverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D94A3A1EA045F100A5FD01 /* OverlayLayoutTests.swift */; }; 75D94A3D1EA045F100A5FD01 /* OverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D94A3A1EA045F100A5FD01 /* OverlayLayoutTests.swift */; }; 75D94A401EA05D5A00A5FD01 /* OverlayViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75D94A3F1EA05D5A00A5FD01 /* OverlayViewController.swift */; }; + AD2C36441EA5AFB500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2C36421EA5AF9500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift */; }; + ADE5FCC11EA5B5F3006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE5FCBF1EA5B5C8006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift */; }; + F6A1A8F51ECAEFC80058DF07 /* LayoutAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A8F41ECAEFC80058DF07 /* LayoutAdapter.swift */; }; + F6A1A8F71ECAF0920058DF07 /* LayoutAdapterCacheHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A8F61ECAF0920058DF07 /* LayoutAdapterCacheHandler.swift */; }; + F6A1A8F91ECAF1040058DF07 /* BatchUpdatesArrayUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A8F81ECAF1040058DF07 /* BatchUpdatesArrayUpdate.swift */; }; + F6A1A8FB1ECAF1380058DF07 /* SafeArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A8FA1ECAF1380058DF07 /* SafeArray.swift */; }; + F6A1A8FE1ECB07AF0058DF07 /* BatchUpdatesArrayUpdateTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A8FC1ECB03B90058DF07 /* BatchUpdatesArrayUpdateTest.swift */; }; + F6A1A9021ECB09880058DF07 /* SafeArrayTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A9001ECB08FA0058DF07 /* SafeArrayTest.swift */; }; + F6A1A90E1ECB513B0058DF07 /* LayoutAdapterWithCacheTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A90C1ECB510E0058DF07 /* LayoutAdapterWithCacheTest.swift */; }; + F6A1A9101ECB661F0058DF07 /* LayoutAdapterWithAutomaticBatchUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A90F1ECB661F0058DF07 /* LayoutAdapterWithAutomaticBatchUpdates.swift */; }; + F6A1A9121ECB66500058DF07 /* BatchUpdatesFromArrayDifference.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A9111ECB66500058DF07 /* BatchUpdatesFromArrayDifference.swift */; }; + F6A1A9151ECB66C00058DF07 /* BatchUpdatesFromArrayDifferenceTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A1A9131ECB66AD0058DF07 /* BatchUpdatesFromArrayDifferenceTest.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -395,11 +405,21 @@ 448CEC0E1E4E0CB500F8AD9E /* TextViewDefaultFont.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextViewDefaultFont.swift; sourceTree = ""; }; 44F968141E425F5D00392763 /* TextViewLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextViewLayout.swift; sourceTree = ""; }; 44F968161E42639500392763 /* TextViewLayoutTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextViewLayoutTests.swift; sourceTree = ""; }; - AD2C36421EA5AF9500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift; sourceTree = ""; }; - ADE5FCBF1EA5B5C8006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReloadableViewLayoutAdapterTableViewOverrideTests.swift; sourceTree = ""; }; 75D94A351EA01B6A00A5FD01 /* OverlayLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverlayLayout.swift; sourceTree = ""; }; 75D94A3A1EA045F100A5FD01 /* OverlayLayoutTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverlayLayoutTests.swift; sourceTree = ""; }; 75D94A3F1EA05D5A00A5FD01 /* OverlayViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverlayViewController.swift; sourceTree = ""; }; + AD2C36421EA5AF9500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift; sourceTree = ""; }; + ADE5FCBF1EA5B5C8006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReloadableViewLayoutAdapterTableViewOverrideTests.swift; sourceTree = ""; }; + F6A1A8F41ECAEFC80058DF07 /* LayoutAdapter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutAdapter.swift; sourceTree = ""; }; + F6A1A8F61ECAF0920058DF07 /* LayoutAdapterCacheHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutAdapterCacheHandler.swift; sourceTree = ""; }; + F6A1A8F81ECAF1040058DF07 /* BatchUpdatesArrayUpdate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchUpdatesArrayUpdate.swift; sourceTree = ""; }; + F6A1A8FA1ECAF1380058DF07 /* SafeArray.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SafeArray.swift; sourceTree = ""; }; + F6A1A8FC1ECB03B90058DF07 /* BatchUpdatesArrayUpdateTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchUpdatesArrayUpdateTest.swift; sourceTree = ""; }; + F6A1A9001ECB08FA0058DF07 /* SafeArrayTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SafeArrayTest.swift; sourceTree = ""; }; + F6A1A90C1ECB510E0058DF07 /* LayoutAdapterWithCacheTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutAdapterWithCacheTest.swift; sourceTree = ""; }; + F6A1A90F1ECB661F0058DF07 /* LayoutAdapterWithAutomaticBatchUpdates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LayoutAdapterWithAutomaticBatchUpdates.swift; sourceTree = ""; }; + F6A1A9111ECB66500058DF07 /* BatchUpdatesFromArrayDifference.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchUpdatesFromArrayDifference.swift; sourceTree = ""; }; + F6A1A9131ECB66AD0058DF07 /* BatchUpdatesFromArrayDifferenceTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatchUpdatesFromArrayDifferenceTest.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -598,6 +618,10 @@ 44F968161E42639500392763 /* TextViewLayoutTests.swift */, 0BCB76671D8725310065E02A /* UIFontExtension.swift */, 0BCB76681D8725310065E02A /* ViewRecyclerTests.swift */, + F6A1A8FC1ECB03B90058DF07 /* BatchUpdatesArrayUpdateTest.swift */, + F6A1A9001ECB08FA0058DF07 /* SafeArrayTest.swift */, + F6A1A90C1ECB510E0058DF07 /* LayoutAdapterWithCacheTest.swift */, + F6A1A9131ECB66AD0058DF07 /* BatchUpdatesFromArrayDifferenceTest.swift */, ); path = LayoutKitTests; sourceTree = ""; @@ -628,6 +652,7 @@ 0B765F2B1DC0514F000BF1FD /* CGFloatExtension.swift */, 4468A31C1E46460B00341D07 /* NSAttributedStringExtension.swift */, 448CEC0E1E4E0CB500F8AD9E /* TextViewDefaultFont.swift */, + F6A1A8FA1ECAF1380058DF07 /* SafeArray.swift */, ); path = Internal; sourceTree = ""; @@ -661,6 +686,8 @@ isa = PBXGroup; children = ( 0BCB75ED1D8724800065E02A /* BatchUpdates.swift */, + F6A1A8F81ECAF1040058DF07 /* BatchUpdatesArrayUpdate.swift */, + F6A1A9111ECB66500058DF07 /* BatchUpdatesFromArrayDifference.swift */, 0BCB75EE1D8724800065E02A /* LayoutAdapterCollectionView.swift */, 0BCB75EF1D8724800065E02A /* LayoutAdapterTableView.swift */, 0BCB75F01D8724800065E02A /* ReloadableView.swift */, @@ -669,6 +696,9 @@ 0BCB75F31D8724800065E02A /* ReloadableViewLayoutAdapter.swift */, 0BCB75F41D8724800065E02A /* ReloadableViewUpdateManager.swift */, 0BCB75F51D8724800065E02A /* StackView.swift */, + F6A1A8F41ECAEFC80058DF07 /* LayoutAdapter.swift */, + F6A1A8F61ECAF0920058DF07 /* LayoutAdapterCacheHandler.swift */, + F6A1A90F1ECB661F0058DF07 /* LayoutAdapterWithAutomaticBatchUpdates.swift */, ); path = Views; sourceTree = ""; @@ -1032,7 +1062,10 @@ 0BCB76051D8724800065E02A /* StackLayout.swift in Sources */, 0BCB760D1D8724800065E02A /* LayoutAdapterTableView.swift in Sources */, 0BCB76071D8724800065E02A /* AxisPoint.swift in Sources */, + F6A1A8F91ECAF1040058DF07 /* BatchUpdatesArrayUpdate.swift in Sources */, + F6A1A8FB1ECAF1380058DF07 /* SafeArray.swift in Sources */, 0BCB75F71D8724800065E02A /* Animation.swift in Sources */, + F6A1A8F51ECAEFC80058DF07 /* LayoutAdapter.swift in Sources */, 0BCB76081D8724800065E02A /* AxisSize.swift in Sources */, 0B193BB81D887BCF00FCA22D /* CollectionExtension.swift in Sources */, 4468A31D1E46460B00341D07 /* NSAttributedStringExtension.swift in Sources */, @@ -1040,10 +1073,13 @@ 0BD5F8291DB43B4500108688 /* ButtonLayout.swift in Sources */, 0BCB76001D8724800065E02A /* LayoutMeasurement.swift in Sources */, 0BCB76021D8724800065E02A /* InsetLayout.swift in Sources */, + F6A1A9121ECB66500058DF07 /* BatchUpdatesFromArrayDifference.swift in Sources */, 0BCB76061D8724800065E02A /* AxisFlexibility.swift in Sources */, 448CEC0F1E4E0CB500F8AD9E /* TextViewDefaultFont.swift in Sources */, + F6A1A8F71ECAF0920058DF07 /* LayoutAdapterCacheHandler.swift in Sources */, 0BCB760E1D8724800065E02A /* ReloadableView.swift in Sources */, 75D94A361EA01B6A00A5FD01 /* OverlayLayout.swift in Sources */, + F6A1A9101ECB661F0058DF07 /* LayoutAdapterWithAutomaticBatchUpdates.swift in Sources */, 0BCB75FD1D8724800065E02A /* CGSizeExtension.swift in Sources */, 0BCB760B1D8724800065E02A /* BatchUpdates.swift in Sources */, 0BCB76011D8724800065E02A /* BaseLayout.swift in Sources */, @@ -1074,6 +1110,10 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + F6A1A9151ECB66C00058DF07 /* BatchUpdatesFromArrayDifferenceTest.swift in Sources */, + F6A1A90E1ECB513B0058DF07 /* LayoutAdapterWithCacheTest.swift in Sources */, + F6A1A9021ECB09880058DF07 /* SafeArrayTest.swift in Sources */, + F6A1A8FE1ECB07AF0058DF07 /* BatchUpdatesArrayUpdateTest.swift in Sources */, ADE5FCC11EA5B5F3006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift in Sources */, AD2C36441EA5AFB500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift in Sources */, 0B765F301DC135B8000BF1FD /* CGFloatExtensionTests.swift in Sources */, diff --git a/LayoutKit.xcodeproj/xcshareddata/xcschemes/ExampleLayouts-iOS.xcscheme b/LayoutKit.xcodeproj/xcshareddata/xcschemes/ExampleLayouts-iOS.xcscheme index afebc451..e00e09b5 100644 --- a/LayoutKit.xcodeproj/xcshareddata/xcschemes/ExampleLayouts-iOS.xcscheme +++ b/LayoutKit.xcodeproj/xcshareddata/xcschemes/ExampleLayouts-iOS.xcscheme @@ -26,7 +26,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> diff --git a/LayoutKitTests/BatchUpdatesArrayUpdateTest.swift b/LayoutKitTests/BatchUpdatesArrayUpdateTest.swift new file mode 100644 index 00000000..051b42ff --- /dev/null +++ b/LayoutKitTests/BatchUpdatesArrayUpdateTest.swift @@ -0,0 +1,176 @@ +import XCTest +@testable import LayoutKit + +class BatchUpdatesArrayUpdateTest: XCTestCase { + + // Delete item + + func test_SingleItem_Delete() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1", "test 2"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["test 1"]]) + let batchUpdates = BatchUpdates() + batchUpdates.deleteItems = [IndexPath(row: 1, section: 0)] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + func test_MultipleItems_Delete() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1", "test 2", "test 3", "test 4"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["test 1", "test 4"]]) + let batchUpdates = BatchUpdates() + batchUpdates.deleteItems = [IndexPath(row: 1, section: 0), IndexPath(row: 2, section: 0)] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + func test_MultipleItems_MultipleSections_Delete() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1", "test 2", "test 3", "test 4"], ["test 5", "test 6"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["test 1", "test 4"], []]) + let batchUpdates = BatchUpdates() + batchUpdates.deleteItems = [IndexPath(row: 1, section: 0), IndexPath(row: 2, section: 0), IndexPath(row: 0, section: 1), IndexPath(row: 1, section: 1)] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + // Delete section + + func test_SingleSection_Delete() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1", "test 2"]]) + let expectedArray = BatchUpdatesViewModel(strings: []) + let batchUpdates = BatchUpdates() + batchUpdates.deleteSections = [0] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + func test_SingleSection_DeleteSecond() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1", "test 2"], ["test 3", "test 4"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["test 1", "test 2"]]) + let batchUpdates = BatchUpdates() + batchUpdates.deleteSections = [1] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + func test_MultipleSection_Delete() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1", "test 2"], ["test 3", "test 4"], ["test 5", "test 6"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["test 3", "test 4"]]) + let batchUpdates = BatchUpdates() + batchUpdates.deleteSections = [0, 2] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + // Insert item + + func test_SingleItem_Insert() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["test 1", "row: 1 section: 0"]]) + let batchUpdates = BatchUpdates() + batchUpdates.insertItems = [IndexPath(row: 1, section: 0)] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + func test_MultipleItem_Insert() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1"], ["test 2"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["row: 0 section: 0", "test 1", "row: 2 section: 0"], + ["row: 0 section: 1", "row: 1 section: 1", "test 2"]]) + let batchUpdates = BatchUpdates() + batchUpdates.insertItems = [IndexPath(row: 0, section: 0), IndexPath(row: 2, section: 0), IndexPath(row: 0, section: 1), IndexPath(row: 1, section: 1)] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + func test_SingleItem_Insert_SectionDoesNotExist() { + let oldArray = BatchUpdatesViewModel(strings: [[]]) + let expectedArray = BatchUpdatesViewModel(strings: [["row: 0 section: 0"]]) + let batchUpdates = BatchUpdates() + batchUpdates.insertItems = [IndexPath(row: 0, section: 0)] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + // Insert section + + func test_SingleSection_Insert() { + let oldArray = BatchUpdatesViewModel(strings: []) + let expectedArray = BatchUpdatesViewModel(strings: [[]]) + let batchUpdates = BatchUpdates() + batchUpdates.insertSections = [0] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + func test_MultipleSection_Insert() { + let oldArray = BatchUpdatesViewModel(strings: []) + let expectedArray = BatchUpdatesViewModel(strings: [[], [], []]) + let batchUpdates = BatchUpdates() + batchUpdates.insertSections = [0, 1, 2] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + // Item reload + + func test_SingleItem_Reload() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["row: 0 section: 0"]]) + let batchUpdates = BatchUpdates() + batchUpdates.reloadItems = [IndexPath(row: 0, section: 0)] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + func test_MultipleItems_Reload() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1"], ["test 2", "test 3"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["row: 0 section: 0"], ["test 2", "row: 1 section: 1"]]) + let batchUpdates = BatchUpdates() + batchUpdates.reloadItems = [IndexPath(row: 0, section: 0), IndexPath(row: 1, section: 1)] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + // Section reload + + func test_SingleSection_Reload() { + let oldArray = BatchUpdatesViewModel(strings: [["test 1"]]) + let expectedArray = BatchUpdatesViewModel(strings: [["row: 0 section: 0"]]) + let batchUpdates = BatchUpdates() + batchUpdates.reloadSections = [0] + + performTest(oldArray: oldArray, expectedArray: expectedArray, batchUpdates: batchUpdates) + } + + // Helpers + + func performTest(oldArray: BatchUpdatesViewModel, expectedArray: BatchUpdatesViewModel, batchUpdates: BatchUpdates) { + if let newArray = try? batchUpdates.updateArray(oldArray.strings, elementCreationCallback: { indexPath in + return "row: \(indexPath.row) section: \(indexPath.section)" + }) { + XCTAssert(BatchUpdatesViewModel(strings: newArray) == expectedArray) + } else { + XCTAssertTrue(false) + } + } +} + +struct BatchUpdatesViewModel { + let strings: [[String]] +} + +extension BatchUpdatesViewModel: Equatable {} + +func == (lhs: BatchUpdatesViewModel, rhs: BatchUpdatesViewModel) -> Bool { + guard lhs.strings.count == rhs.strings.count else { return false } + + for i in 0..(lhs: Any, rhs: Any, type: T.Type) -> Bool { + if let lhs = lhs as? T, let rhs = rhs as? T { + return lhs == rhs + } + + return false +} + +extension BatchUpdates { + open override func isEqual(_ object: Any?) -> Bool { + if let object = object as? BatchUpdates { + return object.insertItems == insertItems && + object.deleteItems == deleteItems && + object.moveItems == moveItems && + object.reloadItems == reloadItems && + + object.deleteSections == deleteSections && + object.insertSections == insertSections && + object.moveSections == moveSections && + object.reloadSections == reloadSections + } + + return false + } +} diff --git a/LayoutKitTests/LayoutAdapterWithCacheTest.swift b/LayoutKitTests/LayoutAdapterWithCacheTest.swift new file mode 100644 index 00000000..7b7dd1e5 --- /dev/null +++ b/LayoutKitTests/LayoutAdapterWithCacheTest.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import LayoutKit + +class LayoutAdapterCacheHandlerTest: XCTestCase { + var layoutAdapterHandler: LayoutAdapterCacheHandler! + var layoutAdapter: MockLayoutAdapter! + + override func setUp() { + layoutAdapter = MockLayoutAdapter() + layoutAdapterHandler = LayoutAdapterCacheHandler(layoutAdapter: layoutAdapter) + } + + func testLayoutProvider_SingleItem_Insert() { + let batchUpdates = BatchUpdates() + batchUpdates.insertItems = [IndexPath(row: 0, section: 0)] + let layoutProvider = { indexPath in + return MockSizeLayout(indexPath: indexPath) + } + + let expectedLayouts = [MockSizeLayout(indexPath: IndexPath(row: 0, section: 0))] + + let newLayout = layoutAdapterHandler + .cachedLayout(batchUpdates: batchUpdates, layoutProvider: layoutProvider) + + if let firstItems = newLayout[0].items as? [MockSizeLayout] { + XCTAssert(firstItems == expectedLayouts) + } else { + XCTAssertTrue(false) + } + } + + func testLayoutProvider_MultipleItems_Insert() { + let batchUpdates = BatchUpdates() + batchUpdates.insertItems = [IndexPath(row: 0, section: 0), IndexPath(row: 1, section: 0), IndexPath(row: 0, section: 1)] + let layoutProvider = { indexPath in + return MockSizeLayout(indexPath: indexPath) + } + + let expectedLayouts = [ + [MockSizeLayout(indexPath: IndexPath(row: 0, section: 0)), MockSizeLayout(indexPath: IndexPath(row: 1, section: 0))], + [MockSizeLayout(indexPath: IndexPath(row: 0, section: 1))] + ] + + let newLayout = layoutAdapterHandler + .cachedLayout(batchUpdates: batchUpdates, layoutProvider: layoutProvider) + + if let firstItems = newLayout[0].items as? [MockSizeLayout], + let secondItems = newLayout[1].items as? [MockSizeLayout] { + + XCTAssert(firstItems == expectedLayouts[0]) + XCTAssert(secondItems == expectedLayouts[1]) + } else { + XCTAssertTrue(false) + } + } + + func testLayoutProvider_SingleItem_Delete() { + let batchUpdates = BatchUpdates() + batchUpdates.deleteItems = [IndexPath(row: 0, section: 0)] + let layoutProvider = { indexPath in + return MockSizeLayout(indexPath: indexPath) + } + layoutAdapterHandler.layouts = [Section(items: [MockSizeLayout(indexPath: IndexPath(row: 0, section: 0))])] + + let expectedLayouts = [MockSizeLayout]() + + let newLayout = layoutAdapterHandler + .cachedLayout(batchUpdates: batchUpdates, layoutProvider: layoutProvider) + + if let firstItems = newLayout[0].items as? [MockSizeLayout] { + XCTAssert(firstItems == expectedLayouts) + } else { + XCTAssertTrue(false) + } + } + + func testLayoutProvider_MultipleItem_Delete() { + let batchUpdates = BatchUpdates() + batchUpdates.deleteItems = [IndexPath(row: 0, section: 0), IndexPath(row: 2, section: 0), IndexPath(row: 1, section: 2)] + let layoutProvider = { indexPath in + return MockSizeLayout(indexPath: indexPath) + } + layoutAdapterHandler.layouts = [ + Section(items: [MockSizeLayout(indexPath: IndexPath(row: 0, section: 0)), + MockSizeLayout(indexPath: IndexPath(row: 1, section: 0)), + MockSizeLayout(indexPath: IndexPath(row: 2, section: 0))]), + Section(items: [MockSizeLayout(indexPath: IndexPath(row: 0, section: 1)), + MockSizeLayout(indexPath: IndexPath(row: 1, section: 1)), + MockSizeLayout(indexPath: IndexPath(row: 2, section: 1))]), + Section(items: [MockSizeLayout(indexPath: IndexPath(row: 0, section: 2)), + MockSizeLayout(indexPath: IndexPath(row: 1, section: 2)), + MockSizeLayout(indexPath: IndexPath(row: 2, section: 2))]) + ] + + let expectedLayouts = [ + [MockSizeLayout(indexPath: IndexPath(row: 1, section: 0))], + [MockSizeLayout(indexPath: IndexPath(row: 0, section: 1)), + MockSizeLayout(indexPath: IndexPath(row: 1, section: 1)), + MockSizeLayout(indexPath: IndexPath(row: 2, section: 1))], + [MockSizeLayout(indexPath: IndexPath(row: 0, section: 2)), + MockSizeLayout(indexPath: IndexPath(row: 2, section: 2))] + ] + + let newLayout = layoutAdapterHandler + .cachedLayout(batchUpdates: batchUpdates, layoutProvider: layoutProvider) + + if let firstItems = newLayout[0].items as? [MockSizeLayout], + let secondItems = newLayout[1].items as? [MockSizeLayout], + let thirdItems = newLayout[2].items as? [MockSizeLayout] { + + XCTAssert(firstItems == expectedLayouts[0]) + XCTAssert(secondItems == expectedLayouts[1]) + XCTAssert(thirdItems == expectedLayouts[2]) + } else { + XCTAssertTrue(false) + } + } +} + +class MockLayoutAdapter: LayoutAdapter { + var reloadLayouts = [Any]() + + func reload( + width: CGFloat?, + height: CGFloat?, + synchronous: Bool, + batchUpdates: BatchUpdates?, + layoutProvider: @escaping (Void) -> T, + completion: (() -> Void)?) where U.Iterator.Element == Layout, T.Iterator.Element == Section { + reloadLayouts.append(layoutProvider()) + } +} + +class MockSizeLayout: SizeLayout { + let indexPath: IndexPath + + init(indexPath: IndexPath) { + self.indexPath = indexPath + } +} + +extension MockSizeLayout: Equatable {} + +func == (lhs: MockSizeLayout, rhs: MockSizeLayout) -> Bool { + return lhs.indexPath == rhs.indexPath +} diff --git a/LayoutKitTests/SafeArrayTest.swift b/LayoutKitTests/SafeArrayTest.swift new file mode 100644 index 00000000..d81e0c9a --- /dev/null +++ b/LayoutKitTests/SafeArrayTest.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import LayoutKit + +class SafeArrayTest: XCTestCase { + func testRemove_Successful() { + var array = [1, 2, 3] + + array.remove(safeAt: 1) + + XCTAssert(array == [1, 3]) + } + + func testRemove_Failure() { + var array = [1, 2, 3] + + array.remove(safeAt: 3) + + XCTAssert(array == [1, 2, 3]) + } + + func testInsert_Successful() { + var array = [1, 2, 3] + + array.insert(4, safeAt: 1) + + XCTAssert(array == [1, 4, 2, 3]) + } + + func testInsert_Successful_AtEnd() { + var array = [1, 2, 3] + + array.insert(4, safeAt: 3) + + XCTAssert(array == [1, 2, 3, 4]) + } + + func testInsert_Failure() { + var array = [1, 2, 3] + + array.insert(4, safeAt: 5) + + XCTAssert(array == [1, 2, 3]) + } + + func testGet_Successful() { + var array = [1, 2, 3] + + let element = array[safe: 1] + + XCTAssert(element == 2) + } + + func testGet_Failure() { + var array = [1, 2, 3] + + let element = array[safe: 4] + + XCTAssert(element == nil) + } + + func testSet_Successful() { + var array = [1, 2, 3] + + array[safe: 1] = 4 + + XCTAssert(array == [1, 4, 3]) + } + + func testSet_Successful_AtEnd() { + var array = [1, 2, 3] + + array[safe: 3] = 4 + + XCTAssert(array == [1, 2, 3, 4]) + } + + func testSet_Failure() { + var array = [1, 2, 3] + + array[safe: 4] = 4 + + XCTAssert(array == [1, 2, 3]) + } +} diff --git a/Sources/Internal/SafeArray.swift b/Sources/Internal/SafeArray.swift new file mode 100644 index 00000000..a1dc4dba --- /dev/null +++ b/Sources/Internal/SafeArray.swift @@ -0,0 +1,49 @@ +/** + Safe array get, set, insert and delete. + All action that would cause an error are ignored. + */ +extension Array { + + /** + Removes element at index. + Action that would cause an error are ignored. + */ + mutating func remove(safeAt index: Index) { + guard index >= 0 && index < count else { + print("Index out of bounds while deleting item at index \(index) in \(self). This action is ignored.") + return + } + + remove(at: index) + } + + /** + Inserts element at index. + Action that would cause an error are ignored. + */ + mutating func insert(_ element: Element, safeAt index: Index) { + guard index >= 0 && index <= count else { + print("Index out of bounds while inserting item at index \(index) in \(self). This action is ignored") + return + } + + insert(element, at: index) + } + + /** + Safe get set subscript. + Action that would cause an error are ignored. + */ + subscript (safe index: Index) -> Element? { + get { + return indices.contains(index) ? self[index] : nil + } + set { + remove(safeAt: index) + + if let element = newValue { + insert(element, safeAt: index) + } + } + } +} diff --git a/Sources/Views/BatchUpdatesArrayUpdate.swift b/Sources/Views/BatchUpdatesArrayUpdate.swift new file mode 100644 index 00000000..08ab527f --- /dev/null +++ b/Sources/Views/BatchUpdatesArrayUpdate.swift @@ -0,0 +1,70 @@ +extension BatchUpdates { + + /// Creates new array updated using BatchUpdates + /// + /// - Parameter array: array to be updated + /// - Parameter elementCreationCallback: callback for element creation + /// - Returns: updated array + func updateArray(_ array: [[T]], elementCreationCallback: (IndexPath) -> T) -> [[T]] { + var newArray = updateArraySections(array, elementCreationCallback: elementCreationCallback) + + newArray = updateArrayItems(newArray, elementCreationCallback: elementCreationCallback) + + return newArray + } + + /// Creates new array with updated sections using BatchUpdates + func updateArraySections(_ array: [[T]], elementCreationCallback: (IndexPath) -> T) -> [[T]] { + var newArray = array + + for deletedSection in deleteSections.sorted(by: { $0 > $1 }) { + newArray.remove(safeAt: deletedSection) + } + + for insertSection in insertSections.sorted(by: { $0 < $1 }) { + newArray.insert([], safeAt: insertSection) + } + + for reloadSection in reloadSections { + if let element = newArray[safe: reloadSection] { + newArray[safe: reloadSection] = [] + + // Reloads all elements in reloaded section + for i in 0..(_ array: [[T]], elementCreationCallback: (IndexPath) -> T) -> [[T]] { + var newArray = array + + for deleteItem in deleteItems.sorted(by: { lhs, rhs in + lhs.row > rhs.row + }) { + newArray[safe: deleteItem.section]?.remove(safeAt: deleteItem.row) + } + + // Create new sections if needed + for insertItem in insertItems where (insertItem.section < 0 || insertItem.section >= newArray.count) { + newArray.insert([], safeAt: insertItem.section) + } + + for insertItem in insertItems.sorted(by: { lhs, rhs in + lhs.row < rhs.row + }) { + newArray[safe: insertItem.section]?.insert(elementCreationCallback(insertItem), safeAt: insertItem.row) + } + + for reloadItem in reloadItems { + newArray[safe: reloadItem.section]?[safe: reloadItem.row] = elementCreationCallback(reloadItem) + } + + return newArray + } +} diff --git a/Sources/Views/BatchUpdatesFromArrayDifference.swift b/Sources/Views/BatchUpdatesFromArrayDifference.swift new file mode 100644 index 00000000..841bd22c --- /dev/null +++ b/Sources/Views/BatchUpdatesFromArrayDifference.swift @@ -0,0 +1,57 @@ +extension BatchUpdates { + static func calculate(old: [[Any]], new: [[Any]], elementCompareCallback: (Any, Any) -> Bool) -> BatchUpdates { + var deleted = [IndexPath]() + var inserted = [IndexPath]() + var moved = [ItemMove]() + + for i in 0.. old.count { + batchUpdates.insertSections = changedSections + + for i in min(new.count, old.count)..( + width: CGFloat?, + height: CGFloat?, + synchronous: Bool, + batchUpdates: BatchUpdates?, + layoutProvider: @escaping (Void) -> T, + completion: (() -> Void)?) where U.Iterator.Element == Layout, T.Iterator.Element == Section +} + +extension ReloadableViewLayoutAdapter: LayoutAdapter {} diff --git a/Sources/Views/LayoutAdapterCacheHandler.swift b/Sources/Views/LayoutAdapterCacheHandler.swift new file mode 100644 index 00000000..bee64577 --- /dev/null +++ b/Sources/Views/LayoutAdapterCacheHandler.swift @@ -0,0 +1,52 @@ +public class LayoutAdapterCacheHandler { + var layouts = [Section<[Layout]>]() + let layoutAdapter: LayoutAdapter + + public init(layoutAdapter: LayoutAdapter) { + self.layoutAdapter = layoutAdapter + } + + /// Reloads reloadableView calculating only needed layouts + public func reload( + batchUpdates: BatchUpdates, + layoutProvider: @escaping (IndexPath) -> Layout, + sectionProvider: @escaping (Int, [Layout]) -> Section<[Layout]> = LayoutAdapterCacheHandler.defaultSectionProvider, + animated: Bool = true, + completion: (() -> Void)? = nil) { + + layoutAdapter.reload( + width: nil, + height: nil, + synchronous: false, + batchUpdates: animated ? batchUpdates : nil, + layoutProvider: { + self.cachedLayout( + batchUpdates: batchUpdates, + layoutProvider: layoutProvider, + sectionProvider: sectionProvider + ) + }, + completion: completion + ) + } + + /// Updates layouts that have changed using batchUpdates + func cachedLayout( + batchUpdates: BatchUpdates, + layoutProvider: @escaping (IndexPath) -> Layout, + sectionProvider: (Int, [Layout]) -> Section<[Layout]> = LayoutAdapterCacheHandler.defaultSectionProvider) -> [Section<[Layout]>] { + + let elements = batchUpdates + .updateArray(layouts.map { $0.items }, elementCreationCallback: layoutProvider) + + layouts = elements + .enumerated() + .map { sectionProvider($0.offset, $0.element) } + + return layouts + } + + static func defaultSectionProvider(index: Int, layouts: [Layout]) -> Section<[Layout]> { + return Section(items: layouts) + } +} diff --git a/Sources/Views/LayoutAdapterWithAutomaticBatchUpdates.swift b/Sources/Views/LayoutAdapterWithAutomaticBatchUpdates.swift new file mode 100644 index 00000000..04c14977 --- /dev/null +++ b/Sources/Views/LayoutAdapterWithAutomaticBatchUpdates.swift @@ -0,0 +1,33 @@ +public class LayoutAdapterWithAutomaticBatchUpdates { + var viewModel = [[Any]]() + + let layoutAdapter: LayoutAdapterCacheHandler + + public init(layoutAdapter: LayoutAdapterCacheHandler) { + self.layoutAdapter = layoutAdapter + } + + /// Reloads data and automaticaly calculates and performs animations + public func reload( + viewModel: [[Any]], + elementCompareCallback: @escaping (Any, Any) -> Bool = { _ in return false }, + layoutProvider: @escaping (IndexPath) -> Layout, + animated: Bool = true, + completion: (() -> Void)? = nil) { + let batchUpdates = BatchUpdates + .calculate( + old: self.viewModel.filter { $0.count > 0}, + new: viewModel.filter { $0.count > 0 }, + elementCompareCallback: elementCompareCallback + ) + + self.viewModel = viewModel + + layoutAdapter.reload( + batchUpdates: batchUpdates, + layoutProvider: layoutProvider, + animated: animated, + completion: completion + ) + } +} diff --git a/Sources/Views/LayoutAdapterWithCache.swift b/Sources/Views/LayoutAdapterWithCache.swift new file mode 100644 index 00000000..bee64577 --- /dev/null +++ b/Sources/Views/LayoutAdapterWithCache.swift @@ -0,0 +1,52 @@ +public class LayoutAdapterCacheHandler { + var layouts = [Section<[Layout]>]() + let layoutAdapter: LayoutAdapter + + public init(layoutAdapter: LayoutAdapter) { + self.layoutAdapter = layoutAdapter + } + + /// Reloads reloadableView calculating only needed layouts + public func reload( + batchUpdates: BatchUpdates, + layoutProvider: @escaping (IndexPath) -> Layout, + sectionProvider: @escaping (Int, [Layout]) -> Section<[Layout]> = LayoutAdapterCacheHandler.defaultSectionProvider, + animated: Bool = true, + completion: (() -> Void)? = nil) { + + layoutAdapter.reload( + width: nil, + height: nil, + synchronous: false, + batchUpdates: animated ? batchUpdates : nil, + layoutProvider: { + self.cachedLayout( + batchUpdates: batchUpdates, + layoutProvider: layoutProvider, + sectionProvider: sectionProvider + ) + }, + completion: completion + ) + } + + /// Updates layouts that have changed using batchUpdates + func cachedLayout( + batchUpdates: BatchUpdates, + layoutProvider: @escaping (IndexPath) -> Layout, + sectionProvider: (Int, [Layout]) -> Section<[Layout]> = LayoutAdapterCacheHandler.defaultSectionProvider) -> [Section<[Layout]>] { + + let elements = batchUpdates + .updateArray(layouts.map { $0.items }, elementCreationCallback: layoutProvider) + + layouts = elements + .enumerated() + .map { sectionProvider($0.offset, $0.element) } + + return layouts + } + + static func defaultSectionProvider(index: Int, layouts: [Layout]) -> Section<[Layout]> { + return Section(items: layouts) + } +}