From eefff2e01bc6ef208376cb2d1f11ab70e6a7641a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 18 May 2020 19:59:07 -0700 Subject: [PATCH 1/2] Replace Binding with a Coordinator, which allows live updating and management of item content. --- BlueprintLists/Sources/Exports.swift | 13 + Demo/Demo.xcodeproj/project.pbxproj | 18 +- .../CoordinatorViewController.swift | 79 ++ Demo/Sources/DemosRootViewController.swift | 7 + .../TestOutput/Test.test.txt | 1 + .../TestOutput/Test.test.txt | 1 + .../iPad Pro (10.5 inch).hierarchy.txt | 7 + .../iPad Pro (12.9 inch).hierarchy.txt | 7 + .../Hierarchies/iPad.hierarchy.txt | 7 + .../Hierarchies/iPhone 5.hierarchy.txt | 7 + .../Hierarchies/iPhone 8 Plus.hierarchy.txt | 7 + .../Hierarchies/iPhone 8.hierarchy.txt | 7 + .../Hierarchies/iPhone X.hierarchy.txt | 7 + .../Hierarchies/iPhone Xs Max.hierarchy.txt | 7 + .../Images/iPad Pro (10.5 inch).snapshot.png | Bin 0 -> 21542 bytes .../Images/iPad Pro (12.9 inch).snapshot.png | Bin 0 -> 31931 bytes .../Images/iPad.snapshot.png | Bin 0 -> 18385 bytes .../Images/iPhone 5.snapshot.png | Bin 0 -> 5287 bytes .../Images/iPhone 8 Plus.snapshot.png | Bin 0 -> 8523 bytes .../Images/iPhone 8.snapshot.png | Bin 0 -> 7215 bytes .../Images/iPhone X.snapshot.png | Bin 0 -> 8717 bytes .../Images/iPhone Xs Max.snapshot.png | Bin 0 -> 10321 bytes .../TestOutput/Test.test.txt | 1 + Listable/Sources/Binding.swift | 267 ------ Listable/Sources/Content.swift | 21 +- .../PresentationState.HeaderFooterState.swift | 166 ++++ .../PresentationState.ItemState.swift | 395 +++++++++ .../PresentationState.SectionState.swift | 99 +++ .../PresentationState.swift | 253 ++++++ .../Sources/Internal/PresentationState.swift | 768 ------------------ Listable/Sources/Item.swift | 134 +-- Listable/Sources/ItemElement.swift | 139 ++-- Listable/Sources/ItemElementCoordinator.swift | 201 +++++ .../Sources/Layout/CollectionViewLayout.swift | 7 + .../ListView/ListView.DataSource.swift | 2 +- Listable/Sources/ListView/ListView.swift | 43 +- Listable/Sources/ReorderingActions.swift | 19 +- Listable/Sources/Section.swift | 13 - Listable/Tests/BindingTests.swift | 13 - ...entationState.HeaderFooterStateTests.swift | 15 + .../PresentationState.ItemStateTests.swift | 383 +++++++++ .../PresentationState.SectionStateTests.swift | 15 + .../PresentationStateTests.swift | 0 .../Tests/ItemElementCoordinatorTests.swift | 81 ++ 44 files changed, 1986 insertions(+), 1224 deletions(-) create mode 100644 BlueprintLists/Sources/Exports.swift create mode 100644 Demo/Sources/CollectionView/CoordinatorViewController.swift create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_different_asset_fails()/TestOutput/Test.test.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_identical_asset_passes()/TestOutput/Test.test.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad Pro (10.5 inch).hierarchy.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad Pro (12.9 inch).hierarchy.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad.hierarchy.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 5.hierarchy.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 8 Plus.hierarchy.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 8.hierarchy.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone X.hierarchy.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone Xs Max.hierarchy.txt create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPad Pro (10.5 inch).snapshot.png create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPad Pro (12.9 inch).snapshot.png create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPad.snapshot.png create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone 5.snapshot.png create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone 8 Plus.snapshot.png create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone 8.snapshot.png create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone X.snapshot.png create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone Xs Max.snapshot.png create mode 100644 Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_no_asset_writes_and_passes()/TestOutput/Test.test.txt delete mode 100644 Listable/Sources/Binding.swift create mode 100644 Listable/Sources/Internal/Presentation State/PresentationState.HeaderFooterState.swift create mode 100644 Listable/Sources/Internal/Presentation State/PresentationState.ItemState.swift create mode 100644 Listable/Sources/Internal/Presentation State/PresentationState.SectionState.swift create mode 100644 Listable/Sources/Internal/Presentation State/PresentationState.swift delete mode 100644 Listable/Sources/Internal/PresentationState.swift create mode 100644 Listable/Sources/ItemElementCoordinator.swift delete mode 100644 Listable/Tests/BindingTests.swift create mode 100644 Listable/Tests/Internal/Presentation State/PresentationState.HeaderFooterStateTests.swift create mode 100644 Listable/Tests/Internal/Presentation State/PresentationState.ItemStateTests.swift create mode 100644 Listable/Tests/Internal/Presentation State/PresentationState.SectionStateTests.swift rename Listable/Tests/Internal/{ => Presentation State}/PresentationStateTests.swift (100%) create mode 100644 Listable/Tests/ItemElementCoordinatorTests.swift diff --git a/BlueprintLists/Sources/Exports.swift b/BlueprintLists/Sources/Exports.swift new file mode 100644 index 000000000..cb3de7717 --- /dev/null +++ b/BlueprintLists/Sources/Exports.swift @@ -0,0 +1,13 @@ +// +// Exports.swift +// Pods +// +// Created by Kyle Van Essen on 5/19/20. +// + +/// +/// Import required dependencies when using BlueprintLists. +/// + +@_exported import BlueprintUI +@_exported import Listable diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 12fe31c55..ffb7c215d 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -17,13 +17,6 @@ 0A58D85123CD3FCF00583C25 /* FlowLayoutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A58D85023CD3FCF00583C25 /* FlowLayoutViewController.swift */; }; 0A590004236A371600F463DA /* CollectionViewDictionaryDemoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A590002236A371600F463DA /* CollectionViewDictionaryDemoViewController.swift */; }; 0A81AAD0245F696600656DF7 /* CustomLayoutsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A81AACF245F696600656DF7 /* CustomLayoutsViewController.swift */; }; - 0A8AEF852315F68400CCB7F3 /* are-we-there-yet.png in Resources */ = {isa = PBXBuildFile; fileRef = 0A8AEF7E2315F68400CCB7F3 /* are-we-there-yet.png */; }; - 0A8AEF862315F68400CCB7F3 /* this-american-life.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 0A8AEF7F2315F68400CCB7F3 /* this-american-life.jpg */; }; - 0A8AEF872315F68400CCB7F3 /* planet-money.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 0A8AEF802315F68400CCB7F3 /* planet-money.jpg */; }; - 0A8AEF882315F68400CCB7F3 /* outside-lands.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 0A8AEF812315F68400CCB7F3 /* outside-lands.jpg */; }; - 0A8AEF892315F68400CCB7F3 /* the-impact.png in Resources */ = {isa = PBXBuildFile; fileRef = 0A8AEF822315F68400CCB7F3 /* the-impact.png */; }; - 0A8AEF8A2315F68400CCB7F3 /* wait-wait.png in Resources */ = {isa = PBXBuildFile; fileRef = 0A8AEF832315F68400CCB7F3 /* wait-wait.png */; }; - 0A8AEF8B2315F68400CCB7F3 /* nancy.png in Resources */ = {isa = PBXBuildFile; fileRef = 0A8AEF842315F68400CCB7F3 /* nancy.png */; }; 0AAA0EB1236367D000B32F63 /* ItemizationEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AAA0EB0236367D000B32F63 /* ItemizationEditorViewController.swift */; }; 0AB8B0D2237CD47A00CBC434 /* ReorderingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AB8B0D1237CD47A00CBC434 /* ReorderingViewController.swift */; }; 0AE855512390933100F2E245 /* Test_Targets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AE855502390933100F2E245 /* Test_Targets.swift */; }; @@ -35,6 +28,7 @@ 0AEB96E122FBCC1D00341DFF /* Blueprint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEB96D222FBCC1D00341DFF /* Blueprint.swift */; }; 0AEB96E222FBCC1D00341DFF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEB96D322FBCC1D00341DFF /* AppDelegate.swift */; }; 0AEB96E322FBCC1D00341DFF /* Font.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEB96D422FBCC1D00341DFF /* Font.swift */; }; + 0AF775AA2474D94C0066CFC6 /* CoordinatorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AF775A92474D94C0066CFC6 /* CoordinatorViewController.swift */; }; 27B4DCE9244F88BE001BA9D9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 27B4DCE8244F88BE001BA9D9 /* Assets.xcassets */; }; 42180BB244DD1F51618E1B34 /* libPods-Test Targets.a in Frameworks */ = {isa = PBXBuildFile; fileRef = C37B21704ABC58A2C22883DB /* libPods-Test Targets.a */; }; C16C0AC6F56D2268011EDFF2 /* libPods-Demo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 32A40D3FB90881FFA78ACE27 /* libPods-Demo.a */; }; @@ -53,13 +47,6 @@ 0A58D85023CD3FCF00583C25 /* FlowLayoutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayoutViewController.swift; sourceTree = ""; }; 0A590002236A371600F463DA /* CollectionViewDictionaryDemoViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CollectionViewDictionaryDemoViewController.swift; sourceTree = ""; }; 0A81AACF245F696600656DF7 /* CustomLayoutsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomLayoutsViewController.swift; sourceTree = ""; }; - 0A8AEF7E2315F68400CCB7F3 /* are-we-there-yet.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "are-we-there-yet.png"; sourceTree = ""; }; - 0A8AEF7F2315F68400CCB7F3 /* this-american-life.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "this-american-life.jpg"; sourceTree = ""; }; - 0A8AEF802315F68400CCB7F3 /* planet-money.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "planet-money.jpg"; sourceTree = ""; }; - 0A8AEF812315F68400CCB7F3 /* outside-lands.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = "outside-lands.jpg"; sourceTree = ""; }; - 0A8AEF822315F68400CCB7F3 /* the-impact.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "the-impact.png"; sourceTree = ""; }; - 0A8AEF832315F68400CCB7F3 /* wait-wait.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "wait-wait.png"; sourceTree = ""; }; - 0A8AEF842315F68400CCB7F3 /* nancy.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = nancy.png; sourceTree = ""; }; 0AAA0EB0236367D000B32F63 /* ItemizationEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemizationEditorViewController.swift; sourceTree = ""; }; 0AB8B0D1237CD47A00CBC434 /* ReorderingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderingViewController.swift; sourceTree = ""; }; 0AE8554E2390933100F2E245 /* Test Targets.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Test Targets.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -75,6 +62,7 @@ 0AEB96D222FBCC1D00341DFF /* Blueprint.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Blueprint.swift; sourceTree = ""; }; 0AEB96D322FBCC1D00341DFF /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 0AEB96D422FBCC1D00341DFF /* Font.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Font.swift; sourceTree = ""; }; + 0AF775A92474D94C0066CFC6 /* CoordinatorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoordinatorViewController.swift; sourceTree = ""; }; 27B4DCE8244F88BE001BA9D9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 32A40D3FB90881FFA78ACE27 /* libPods-Demo.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Demo.a"; sourceTree = BUILT_PRODUCTS_DIR; }; C2560A932411830D00F6B31E /* AutoScrollingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoScrollingViewController.swift; sourceTree = ""; }; @@ -175,6 +163,7 @@ 0A3FFA6C238336870080E0D9 /* InvoicesPaymentScheduleDemoViewController.swift */, 0A58D85023CD3FCF00583C25 /* FlowLayoutViewController.swift */, 0AEA09BC242A941700F9ED0C /* SwipeActionsViewController.swift */, + 0AF775A92474D94C0066CFC6 /* CoordinatorViewController.swift */, ); path = CollectionView; sourceTree = ""; @@ -400,6 +389,7 @@ 0AEB96E122FBCC1D00341DFF /* Blueprint.swift in Sources */, 0A81AAD0245F696600656DF7 /* CustomLayoutsViewController.swift in Sources */, 0AAA0EB1236367D000B32F63 /* ItemizationEditorViewController.swift in Sources */, + 0AF775AA2474D94C0066CFC6 /* CoordinatorViewController.swift in Sources */, 0AEB96E322FBCC1D00341DFF /* Font.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Demo/Sources/CollectionView/CoordinatorViewController.swift b/Demo/Sources/CollectionView/CoordinatorViewController.swift new file mode 100644 index 000000000..35a0e020e --- /dev/null +++ b/Demo/Sources/CollectionView/CoordinatorViewController.swift @@ -0,0 +1,79 @@ +// +// CoordinatorViewController.swift +// Demo +// +// Created by Kyle Van Essen on 5/19/20. +// Copyright © 2020 Kyle Van Essen. All rights reserved. +// + +import BlueprintLists +import BlueprintUICommonControls + + +final class CoordinatorViewController : UIViewController +{ + let listView = ListView() + + override func loadView() { + self.view = self.listView + + self.listView.setContent { list in + + list += Section(identifier: "section") { section in + section += CoordinatedElement() + section += CoordinatedElement() + section += CoordinatedElement() + } + } + } +} + + +fileprivate struct CoordinatedElement : BlueprintItemElement, Equatable +{ + var string : String = "" + + var identifier: Identifier { + return .init("") + } + + func element(with info: ApplyItemElementInfo) -> Element { + return Label(text: self.string) + } + + func makeCoordinator(actions: CoordinatorActions, info: CoordinatorInfo) -> Coordinator + { + Coordinator(actions: actions, info: info) + } + + final class Coordinator : ItemElementCoordinator + { + typealias ItemElementType = CoordinatedElement + + let actions: CoordinatorActions + let info: CoordinatorInfo + + var view : View? { + didSet { + + } + } + + init(actions: CoordinatorActions, info: CoordinatorInfo) + { + self.actions = actions + self.info = info + + Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in + guard let self = self else { + timer.invalidate() + return + } + + self.actions.update { + $0.element.string += " \($0.element.string.count)" + } + } + } + } +} diff --git a/Demo/Sources/DemosRootViewController.swift b/Demo/Sources/DemosRootViewController.swift index 09e4664f4..ab7afe0bc 100644 --- a/Demo/Sources/DemosRootViewController.swift +++ b/Demo/Sources/DemosRootViewController.swift @@ -124,6 +124,13 @@ public final class DemosRootViewController : UIViewController onSelect : { _ in self.push(SwipeActionsViewController()) }) + + section += Item( + DemoItem(text: "Item Element Coordinator"), + selectionStyle: .tappable, + onSelect : { _ in + self.push(CoordinatorViewController()) + }) } list += Section(identifier: "flow-layout") { section in diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_different_asset_fails()/TestOutput/Test.test.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_different_asset_fails()/TestOutput/Test.test.txt new file mode 100644 index 000000000..ff74ef065 --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_different_asset_fails()/TestOutput/Test.test.txt @@ -0,0 +1 @@ +New \ No newline at end of file diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_identical_asset_passes()/TestOutput/Test.test.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_identical_asset_passes()/TestOutput/Test.test.txt new file mode 100644 index 000000000..62a98e35d --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_identical_asset_passes()/TestOutput/Test.test.txt @@ -0,0 +1 @@ +Result \ No newline at end of file diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad Pro (10.5 inch).hierarchy.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad Pro (10.5 inch).hierarchy.txt new file mode 100644 index 000000000..ff8af9d76 --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad Pro (10.5 inch).hierarchy.txt @@ -0,0 +1,7 @@ +[ViewType1: 0.0, 0.0, 834.0, 1112.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad Pro (12.9 inch).hierarchy.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad Pro (12.9 inch).hierarchy.txt new file mode 100644 index 000000000..800d3486e --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad Pro (12.9 inch).hierarchy.txt @@ -0,0 +1,7 @@ +[ViewType1: 0.0, 0.0, 1024.0, 1366.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad.hierarchy.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad.hierarchy.txt new file mode 100644 index 000000000..d3e0e5025 --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPad.hierarchy.txt @@ -0,0 +1,7 @@ +[ViewType1: 0.0, 0.0, 768.0, 1024.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 5.hierarchy.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 5.hierarchy.txt new file mode 100644 index 000000000..b065d984b --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 5.hierarchy.txt @@ -0,0 +1,7 @@ +[ViewType1: 0.0, 0.0, 320.0, 568.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 8 Plus.hierarchy.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 8 Plus.hierarchy.txt new file mode 100644 index 000000000..ee5a89c4e --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 8 Plus.hierarchy.txt @@ -0,0 +1,7 @@ +[ViewType1: 0.0, 0.0, 414.0, 736.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 8.hierarchy.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 8.hierarchy.txt new file mode 100644 index 000000000..6be151081 --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone 8.hierarchy.txt @@ -0,0 +1,7 @@ +[ViewType1: 0.0, 0.0, 375.0, 667.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone X.hierarchy.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone X.hierarchy.txt new file mode 100644 index 000000000..ebe62df9e --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone X.hierarchy.txt @@ -0,0 +1,7 @@ +[ViewType1: 0.0, 0.0, 375.0, 812.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone Xs Max.hierarchy.txt b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone Xs Max.hierarchy.txt new file mode 100644 index 000000000..5760dbada --- /dev/null +++ b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Hierarchies/iPhone Xs Max.hierarchy.txt @@ -0,0 +1,7 @@ +[ViewType1: 0.0, 0.0, 414.0, 896.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] + [ViewType2: 10.0, 10.0, 50.0, 50.0] + [ViewType3: 15.0, 15.0, 30.0, 30.0] diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPad Pro (10.5 inch).snapshot.png b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPad Pro (10.5 inch).snapshot.png new file mode 100644 index 0000000000000000000000000000000000000000..e9aba0b71dad85e56c925b1b233c977584cc0fa2 GIT binary patch literal 21542 zcmeAS@N?(olHy`uVBq!ia0y~yV0L0)V2R*h1B&QuNoEI9jKx9jP7LeL$-D$|Tv8)E z(|mmyw18|52FCVG1{RPKAeI7Rp!Nlf49q~95hS*N2`0j%9>cGLNs7N;_HO6x;y#D&@pQGnLpJlIlA&C^b4jez-PMA|A@agL(qSC{t zlZeSRn4xrFeanT<{dTY7{~9B*?tun@p#M96eQ!T6Z%10T#9~3evxn*GG=wp15)p<9 zEm!K-{R^+F+r23*tHH}Brb%-6~-fG$?NFyP^vAe9!*Q+Euw|2QXS24$P4sG)6!^K zqSj`Q(X=$0mPXSOJ-1hl=D5)uH=5%{a~vJFE;5ehxX~Opn&U=u9KpTcqm7f%qH?sT z94#tGi^|cWa`Ze4x*fSPTJ4TjyQ9_aXtg_9?T%Kvqt)(cwL5~3SsNXz99?rl*W(UH g+unn(?cKOyMV9_;_ycZBP(rn zYDCE}+0@O*>I`FsZ79TAUZZ*FSVw_Qw}O z?`vsq?>pz7?|t99_Z;5J&Do%jGsO`?^x2PP<`JTyhcOOqEN#>s-M`X?dpU1I207ll zY@9yHeE#X|(oLJlYI>|C+?(5iAK>US6aA&1@o_{=TaNw^pKAWE8sbyWAICU^H@5bv z&(LPswoLnG+Ny-tXwyO;5<;zTY;!N#{qqUhlI+a%&DGq*xum+Hg2gfElkVh>`t$kv z;#x!eBTreIt)`NF)4w;`vhq4lcXp;-Kgp?e7M?Hf_&hiv+w_-pkwHp^=x0ht;*wk>GO)2-ns`Ev+FEV zYt3}3Y>bz@$lG`iF&GQ3)i~DtU3n*a&cX>O8Mx@a!qe@Cx>>*KQmFe>p1$uTFIo&1 zGtp!@IvV@C*AKnMI;$>LLpa;G%&A)HR@KTS-S1sKI`*K%U3Rtm9F4vD&I^lbK^==7|+060e$~jJaiJ z{eiNwWGgdh{zHzlk=$9ls77Ukiz9XNYrXVnQSYtBfdYCQhAG5f&wz4y!fYpkX*sM@z^c2&)S!I^e#3u90E zZPB7(scLevAET&-z=5IQ5=o_VV}@!38FgYPbgFoU%;Pv5Cwh)uUEi_Fz>|m zS70i@2jByv&BCe>`~mzSq@jQhzz5(%8}v2wHS{&^Un#AC03U!4zz5(1@B#OiaDRze zeuI95euRF6e$;^o^Hl-<`c$4=zi>|MO{fZ>oR{sGm6sNXl9avipgf;GN`m-!zC6)fIl(^BB`*Y^ zoT6LOWS3{@D<@Xp%F+ukvY3~RnLN*)X+aC6o9&p%f|0@{KL8ih7~%3Hz+`$IzbWN# zfXTq*m0%Y_*_}|CA5b_k_xi@_S%3?`1>hpM4hFc0)RPJ?1pqFnMQ(jXLtrv68JG-A zj=(XyJPS+)CQELvhe}RD@j>y0cXNfdG=aj2Tf0Ev#45yq3o%Tf_#zYoZ~?f$YIP(W zuq8kYhbXncAx0oZAVw&;QvNR!Sbm~>3+zIKQ-~3W5x_;{ssVN(h68qWirO4v1Y+d> z{%bfaq+%fz3#ma&#jVZ=oI;F1jDTH4!U6X##BhKZffz9sATbAtIpMV05P!a15_6XS Zxo75~kvk5#XzDJ>el#cZc!u-Ue*wvXDnS4M literal 0 HcmV?d00001 diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPad.snapshot.png b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPad.snapshot.png new file mode 100644 index 0000000000000000000000000000000000000000..545231f3f8e49a74e0860ba85667142481d3d90e GIT binary patch literal 18385 zcmeAS@N?(olHy`uVBq!ia0y~yU&u`8WOFbuwr2tr1C;==6c7WoFJJ^4!3Yvtzyy~-sV}mp>Fc^J)Pzj`DGF|*b zfHV_G3`%K0XpoSq&4vob13;R?)5S5Qg7M8w$E=V*5!Z`n>a9$BXI&Owxvuv>>x;tm z4QH=+q-(zUz5m|(?~nWUGqXt~G%!L4DYkt)cO`C}D@%ikNc_lkVEDoGh+BY}$KU`1 zh;TU3z`)VF_E)GqT&RITfT3$X)DVdTg%k&dmSbUmkc1q7L2&duSU-pmFj8P}O4hDJ z5>jAbX?T~Nyd=8+VUtI$zmo428}v+h&N7PWE7B!{-})*Vq{cc5J7}2G_Ze&B7_d~ zvmAK+wd&uo`Oo?7FE}FtHNZ@Pp@#9p$$qqm(3`&Q{@tbRFwq~_y}Ii9(`J~G1ZqUr zkO@%wq+Xf?hrJNPdHXMmt3vhRdC}n}FneB&liYRxZv7Wnia&51>*IQ|3+?xGc-%=kix3ydeoPDM;l@8xM7aHtoU8G@%Du_sFw>&u_{`>^~K(9W>{ct z$j6@0zx_?xj^;h2k_u9I#b;lv7H5ZL<%Ij#Q_-8>NAsMejUb_L;3`%{`>rkeuEz(D zs(sjtfE(Yt@3xtxK#H`+)mRnPu62DMD+7xMP;6lZ`pRJnxIA#6tLwqT0)JMP{8K#Xq7iw^^qr(QH!v?@P>d|3? z(P0DNLWI#_gVA9F;A)f6VS`~bZ197Xf#LuEiOHAh85k7*gVsd7y=lnHV8Fw&@xyU3 zzuvCLHd7q#pVr*G`}}8i*yKBKPTYj2XQ8z7?VINiV!*lm0>(ndB-nBy3CQB4(EYCv z>y@ysTNsSXhp;X|p!!;^(cnN?x;7ddqrm}PDuiWW<7ny_O&z1D1H48K>!Q8U)G?Ym zMpFklOXf#wmeI0fwCoryJ4VY6igzE47Nw&_>1a_pT9l3!rK3eDVyi3m{bQuVr>mdK II;Vst0HH(*?EnA( literal 0 HcmV?d00001 diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone 5.snapshot.png b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone 5.snapshot.png new file mode 100644 index 0000000000000000000000000000000000000000..bd6b6df5aa3bb5fa174e4796b5ff5cc5cd5cb61d GIT binary patch literal 5287 zcmeAS@N?(olHy`uVBq!ia0y~yV02($V6xy~1Bys<8q5Y#jKx9jP7LeL$-D$|Tv8)E z(|mmyw18|52FCVG1{RPKAeI7R1_tH@j10^`nh_+nfC(nc=&*np#%8hrDb&sU7XqYY zGF|*ZG!sY+s7(e)Xh3KXn_EysqiPNV1J_+o7srqa#y58i^FpFUTmqlj%laQK&T{!M zNxex}GNf8jarNQi>W#7XKlkt3zu*2}6*HTJhSZNxn_Fl9Z8Sb`K%SW?gYmaM1KTDx z7J+{{b=nuWL>w4J9AJFz3rvLgHtZjaKe1Q6kW6@BsL&v`fc1pKwFZVm^N;>t!(hL= zJAi>TfI;$vlz_vJ%|CSazx!0ZuNr6pV~Rqy1H;4fkLtr3=HFG-XkgN4;7Q@-Q1}!1 zXIgyuPuu%@8MqcO7)>x$X!wx+(O&z&`FBDnPC31P_n&$1a8_w@_Me%`HeE!G1`-@Thxt?);HCkxwyZ5NN{yEU+ zOz<#^W48M)wh-C)=$gGpzyCW23`N!njcXVgTkO04?`5lbr|X1l{PrJJ-S7X-=*J+zRUN#zxRFJ)dq%NZ|DDgyZ=}Jt=RAJ5y-moHSbQp^eUPY*-fuj z6<)8si4yQjOX9s&=c^*8fYqyy#ePrKMi!cOsaA9CzNyF|vwoda`Q7Pjk%gkYzAxSK zD-78c`*&sCDUIKTEHvscM9vxwF{FZLG>yTF!_iy`FO)}%i_zlZ&uEndZz}z#PbIz| z*i`=iKjLVeAp-*^1E`_<_Ie;MgCY;>!C%+I{7qim`02oqlJ_~U?7QW?21d3nwq+HW z<=;TfT617Sbb~!5ty+{u9}%tKQT4#qC<(oW(eN4#uhH}Z?RAfqF9b&!M$3!Q@?s>H a7dCT+F3&sux=Ri;9O&ul=d#Wzp$P!AW(sov literal 0 HcmV?d00001 diff --git a/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone 8 Plus.snapshot.png b/Internal Pods/Snapshot/Tests/Snapshot Results/SnapshotTests.swift/13.5/test_image_and_text_output()/Images/iPhone 8 Plus.snapshot.png new file mode 100644 index 0000000000000000000000000000000000000000..ca2baf5b928d5380c69c144b3410a23d2dd9725a GIT binary patch literal 8523 zcmeAS@N?(olHy`uVBq!ia0y~yV4TOm!1REF4Jfi?kDxM;Vk{1FcVbv~PUa<$u~Df7Id20VeeM7A@$1*GZ`Vs39AIGP31PEaRexeXvnex=!GQzu%!eKyeSY*b zP+|#N3;Pa{IY+ZU8Xai(z|sE!qSu!Lg#Gy^ku(y|CdI$E+57U}yZ?FFTHGsI+8Nm-Ja{?y3p96_uhF${zIy(^W;6*4@6R`vzWn#@KO3`W zg`K_q{`o(C@Be@DA+P&);~!7c8yfHDI8^KbLWzWi#w!gU7^XMtp0!Qi2N@tcMOGVqk0bTZ&^~a2Tn;dH#F%zli8-V!#ygVGZNMjU|_sJ{JFXGroe@ z`2X}_2@I}J$4d1q|tK5nj^oxO02g>0|N#nbFS4fyZ`X(!2@^EEKXOw7ryBB&bwV` zA(5B5@h9JUeztdL7F%e4+$VJR+}kkpoTuG=w!ETtGzykJ7>xp0`8}Eo;C16@K?H3j zj21+&=E7)Q01JuH>KNXz9<7d{U5wEd(P*P|v{5>m3x+^0_y-(~`TyUtt7k6*13wpN z2d-8i!E*~91wjkf9K^B?m9d!dYMTFk+*Pvlleg4p{QAAWrN z`Ee?u7y4*xg=7MH&#`Q6EE{?^b4_gabVl?ZTK4r@K(o*~wzsyHjT#ASZ)_e7OQcb@ z(L{ta9%wO|dyvP&MvD!kQNz(<19@k(c<@~!*1dh;Em^zM}S3GzQgIJ`w!cuUQMg3t*`(8<2jqe0S2}xoc$p;>%8SBtB@Q@DU@YSATXic0 zsKepilXbTY8X6UtpUBCCZe0n~@nHVbf5JcczbrV;z-HITSQP%QZc;J>vm@IHTZ>of zA)WgJYm*<$WGqrIzY4U5MN;AWgB4xRc4pi;UJFu=EIdCz^`G0<-o|vc6Lar`A{;pD z`JzJk?>|{kh3lNA{*blx{uY~c`l6V{$HG-kMcwAOH`yi*ni^h>({TJ zKe~VapZ?K?3bsSFci5SE*-pgn{O#EXbKh(yMuvK!x!&)lX2V}H56+<=f2;ZB?vMa#XIZWDsNuw%SUtHv)lL5uPU)gp_&mI z*ZXPHV%htsA^-L4_PysBxnz3q+)YDXV>wSq0cTUD`O@u|l?ELEaq1_T0sAhy# znw~n{S5^Z|4wH9h9cEx_;+argaK-%oH@nqhh8Gf0%vkj)Ysz%lw?EoYBP@U4+uAVQ zqy+~NX8aM~`Jd9jGW*-2_0fD)K97wt zB3=F0DZfAI{QXJq9@|MAI3NT}XMc{xF`bF#;AaL0FH#hFy<9fs&fUAyQ3GLD2>-oB zX@&);{t8)I672Qv-8*m8;Ei3yo*%ebVh3u7tqR(usajtCT@^Lr^sY8;Vh|4^rBB|y z+jj&Jx}X?ZTJ=H9w7U^4Woj**9aL0a9-o8~Z?A%EE4pVLJ;00VxT%+XHFxgbt#613 zcVK=lT`9N6^=#4uv`8KeX5_MHG?|Dp{it}`(3RD*iGZ?8M@G8po(Z2WLs%&*rg`N?s? z9TAJ)zIpfgCp(Y90f*BEEHYj%c=vK1kHH7tj@{F{r%N;Qu<&+Ne^@bX_S0hw%#ZZm zg~5fwW?vl@0te1$NWhZGXh@8P#Au-~S}2TG6dzWMmW88b;b>VnS{9DaY>Ev zO!M_+&;qhK7#Q0#8CXC{fLIEMfm#Fw69GJ&=;g zbny=X(o7&Rptfm1LIXmB*g|fC9? z$s@obEZ^aD)BT5SQ?I7g)z;Vl|M8qn;s66%6we={jO>8wTf)o|KMphO{QdONbZIsN zfs}?j&5O0npSCgbOt9X$+KeYb;RM4@cK6WSRlLj+3gt!Nxe^B)CNLK9_pQ1W0@UH~ z?#a4a1`Ul0%unQGLbt92>Uc2!=|AD0{9hIvXJE5yWGo7QS2rn{f!UGmgssIZ^^nf} zfwjpGW-=D3mtO^1!y>8h{lSW^XFD_Q9Ipi_M;4wRp!(14Yj0yZ+lje%LJrYqb^kBU&`0?p_a!RQ9_&By_x0=7&mY~t z|4;vDLj~KR+B@vbylf|8cmDS5gE{fk>cF_NEsv#9?cDcs+xN(=9gS?vPyF72!idAj z;oZT&so{(FyvaCjjv9)!pL5@D&JqL}jpCj4Zk0DL_T{6w@7e8p=~tE5q)^QWjqCli zX|e2m)R6yrcKhD*t6X_ZP|aAiN9t*+yY;@rhC3%?w_EZqkB- z2s8eO@BGj6ce$5TXp*~i5-8&N>yn%QNmhL?obL}Z^savqESddn(fVky+Q0bpHONcaQBP4jd2yrn5iC;+W1vbMP~RgBK}^yk0Jwa_8>d>8OFQD}?{vqBO$- zRDXpmEeZB|_wJoHYVgLcV$ToUEU^PM#8w6E(o`)k|E`J}ae7yqx37rgxq}vtns=v6 z+P(WX(5c8tV*0Ct(N}cX@=zVOYD%8h$-8%JJ5j^Y`|AVUEHQ`&k#eDz`O+uv-t9Yr z2whMNEv@<>X4>6|mNK=L&JHRnFON?`iMLllwiVs8jvnAeb==fTzM4CC@76a&ggY=l zm#&oC<9asf0a_%F1~YP5G#boEb;4+BMijH7g$8m}K3Zsu78;{fAX1Zhw55U6a~N%D zAUC;38+1rrsnJ4Xw9pvso*<=xPy)TcI^ZbN|Ns6qXL%VI_zgj$LvOD;@-i6mux$Kr zT+FZ6EBVQB!5tBc-@bYG`6oM%!2yTU2P`sPFL?KI9*@BX-j3bVyQfPt^RV!CRDW18 zZT8b+49t)8-i5)1!e(C`6#@s&Xh^`4$!JK7hQw&0Fj^>#RumsrjFyF?W#MR9I9e8t kmW88b;b^aEsC5?EWhVbvk44ofy`glX(f`xTHpS zruq6ZXaU(A42rk6HJzI-U4PAo4Em`u-taxWgsP! z>Ea&(q?tfsKy3$rga(8Lu|$B+ufH+K!QBmxCo9Pj?O+ST?>C3%jO zfbgE!sMLa-w*im7_W$|#_3PKS>!l42Ffj9ku-UDuKe3KYAJ{ zv4pLKeTT@LquC#g4m5n==>GsxzLN1FqaJJe+o;_<1`0NZlx={T9I_lL8oUoi?!KnS z%mY-%!4EWQf|P*VgP?+I>$U>TvgbSh{e=9Bh3O59Z1YSNY~p|rN#m^ZdcNPw|C>t) z$UPL4W0Ocw0IE5#^ufIi+y3x`*)yL9nuQ{vb2feYvfsP^OQTzt9}!*0wt9YJIxm_# zrajy2efjU*|GaE1?iDTVjBFAfyd3-mnmf$b=-M}5J%3;`nuLY-=bKAk{(JYIjoGup z&fb3i{2#yf|3CSV*ZsTkkEiJkjrVgLD)s=OL_$O3m4*)t)0=*u)jq$y`l-Q(^XS3# zG4d}d!37M~Lx&MDur>QF#W65Aj8xz}|GoQPMD#T=U<&!LhVkLXl1ob;i~qYBUqNjA ze|Y>Mg5$g%%lGnnHi-yB1)FJpyGp|93TxUq3<}KAlVs=jsV~a%wkJ7M0HYldQ_+z* z`wqWuZrsfXOjZvQfvGZx`4Dpq*Ez2}>s_`N?-pf74X-<=l0M7E$@APnPgY@%YE^g5 zy}cGa<%WsPe*fWD!h<|Ci#H3s*S`4nPTo6e=*4v|j0fUk`*Xo$vfB5y_fxBoHr>ouzUvzut-7d6{ z$V=V$lW#pg+dDLiEwn%G6S{luZ5Vpa)9yZ7UQs(51xp`{Mggq+9?b>tx^c81g0>Py z3nEx^VYDuQg~Vue3~yMER>#mT#%POZv{5?RC>_lOqq$%-7mVhD(Of|LT=0M&II8;p z|8DJBwhRnH9-yJrx7QDHGAQt{9Q+a=U1fCR$i8O}qbD@lrkBru%nKY%Vr0`|4vu{y zw?Y!c-pBax -{ - private(set) public var element : Element - - private var state : State - - private typealias UpdateElement = (AnyBindingContext, Any, inout Element) -> () - - private enum State - { - case initializing - case new(New) - case updating(Updating) - case discarded - - struct New - { - let context : AnyBindingContext - let updateElement : UpdateElement - } - - struct Updating - { - let context : AnyBindingContext - let updateElement : UpdateElement - - var onChange : OnChange? = nil - } - } - - public init( - initial provider : () -> Element, - bind bindingContext : (Element) -> Context, - update updateElement : @escaping (Context, Context.Update, inout Element) -> () - ) - { - self.state = .initializing - - self.element = provider() - - let context = bindingContext(self.element) - - context.didUpdate = { [weak self] (update : Context.Update) -> () in - self?.contextUpdated(with: update) - } - - self.state = .new(.init( - context: context, - updateElement: { context, contextUpdate, element in - updateElement(context as! Context, contextUpdate as! Context.Update, &element) - })) - } - - deinit { - self.discard() - } - - public typealias OnChange = (Element) -> () - - public func onChange(_ onChange : OnChange?) - { - switch self.state { - case .initializing, .new, .discarded: break - - case .updating(let state): - var newState = state - newState.onChange = onChange - - self.state = .updating(newState) - } - } - - public func start() - { - switch self.state { - case .initializing, .updating, .discarded: break - - case .new(let new): - self.state = .updating( - .init( - context: new.context, - updateElement: new.updateElement, - onChange: nil - ) - ) - - new.context.anyBind(to: self) - } - } - - public func discard() - { - switch self.state { - case .initializing, .new, .discarded: break - - case .updating(let state): - self.state = .discarded - state.context.anyUnbind(from: self) - } - } - - private func contextUpdated(with update: Any) - { - switch self.state { - case .initializing, .new, .discarded: break - - case .updating(let state): - OperationQueue.main.addOperation { - state.updateElement(state.context, update, &self.element) - state.onChange?(self.element) - } - } - } -} - - -public protocol AnyBindingContext : AnyObject -{ - func anyBind(to binding : Binding) - - func anyUnbind(from binding : Binding) -} - - -public protocol BindingContext : AnyBindingContext -{ - associatedtype Element - associatedtype Update - - typealias DidUpdate = (Update) -> () - - // Call this closure when your context needs to signal an update. - // Set by the system when creating the context. You should not set this value yourself. - var didUpdate : DidUpdate? { get set } - - func bind(to binding : Binding) - func unbind(from binding : Binding) -} - -// TODO: Rename to BindingDataSource? BindingSource? -public extension BindingContext -{ - // MARK: AnyBindingContext - - func anyBind(to binding : Binding) - { - let binding = binding as! Binding - - self.bind(to: binding) - } - - func anyUnbind(from binding : Binding) - { - let binding = binding as! Binding - - self.unbind(from: binding) - } -} - -public final class KVOContext : BindingContext -{ - public typealias Update = NSKeyValueObservedChange - - public var didUpdate : DidUpdate? - - public private(set) weak var observed : Observed? - public let keyPath : KeyPath - - private var observation : NSKeyValueObservation? - - public init(with observed : Observed, keyPath : KeyPath) - { - self.observed = observed - self.keyPath = keyPath - } - - deinit { - self.observation?.invalidate() - } - - public func bind(to binding: Binding) - { - self.observation = self.observed?.observe(keyPath) { [weak self] observed, change in - guard let self = self else { return } - - self.didUpdate?(change) - } - } - - public func unbind(from binding: Binding) - { - self.observation?.invalidate() - } -} - -public final class NotificationContext : BindingContext -{ - public var didUpdate : DidUpdate? - - public let center : NotificationCenter - public let name : Notification.Name - public let object : AnyObject? - - public let createUpdate : (Notification) -> Update - - public init( - center : NotificationCenter = .default, - name : Notification.Name, - object : AnyObject? = nil, - createUpdate : @escaping (Notification) -> Update - ) - { - self.center = center - self.name = name - self.object = object - - self.createUpdate = createUpdate - } - - deinit { - self.center.removeObserver(self) - } - - @objc private func recievedNotification(_ notification : Notification) - { - self.didUpdate?(self.createUpdate(notification)) - } - - // MARK: BindingContext - - public func bind(to binding: Binding) - { - self.center.addObserver(self, selector: #selector(recievedNotification(_:)), name: self.name, object: self.object) - } - - public func unbind(from binding : Binding) - { - self.center.removeObserver(self) - } -} - -public extension NotificationContext where Update == Notification -{ - convenience init( - center : NotificationCenter = .default, - name : Notification.Name, - object : AnyObject? = nil - ) - { - self.init( - center: center, - name: name, - object: object, - createUpdate: { $0 } - ) - } -} diff --git a/Listable/Sources/Content.swift b/Listable/Sources/Content.swift index ef89a73e6..a6604fff6 100644 --- a/Listable/Sources/Content.swift +++ b/Listable/Sources/Content.swift @@ -167,7 +167,7 @@ public struct Content // MARK: Slicing Content // - internal func sliceTo(indexPath : IndexPath, plus additionalItems : Int) -> Slice + internal func sliceTo(indexPath : IndexPath, plus additionalItems : Int = Content.Slice.defaultCount) -> Slice { var sliced = self @@ -197,28 +197,11 @@ public struct Content } -public extension Content -{ - func elementsEqual(to other : Content) -> Bool - { - if self.sections.count != other.sections.count { - return false - } - - let sections = zip(self.sections, other.sections) - - return sections.allSatisfy { both in - both.0.elementsEqual(to: both.1) - } - } -} - - internal extension Content { struct Slice { - static let defaultSize : Int = 250 + static let defaultCount : Int = 250 let containsAllItems : Bool let content : Content diff --git a/Listable/Sources/Internal/Presentation State/PresentationState.HeaderFooterState.swift b/Listable/Sources/Internal/Presentation State/PresentationState.HeaderFooterState.swift new file mode 100644 index 000000000..385a87154 --- /dev/null +++ b/Listable/Sources/Internal/Presentation State/PresentationState.HeaderFooterState.swift @@ -0,0 +1,166 @@ +// +// PresentationState.HeaderFooterState.swift +// Listable +// +// Created by Kyle Van Essen on 5/22/20. +// + +import Foundation + + +protocol AnyPresentationHeaderFooterState : AnyObject +{ + var anyModel : AnyHeaderFooter { get } + + func dequeueAndPrepareReusableHeaderFooterView(in cache : ReusableViewCache, frame : CGRect) -> UIView + func enqueueReusableHeaderFooterView(_ view : UIView, in cache : ReusableViewCache) + + func applyTo(view anyView : UIView, reason : ApplyReason) + + func setNew(headerFooter anyHeaderFooter : AnyHeaderFooter) + + func resetCachedSizes() + func size(in sizeConstraint : CGSize, layoutDirection : LayoutDirection, defaultSize : CGSize, measurementCache : ReusableViewCache) -> CGSize +} + + +extension PresentationState +{ + final class HeaderFooterViewStatePair + { + var state : AnyPresentationHeaderFooterState? { + didSet { + guard oldValue !== self.state else { + return + } + + guard let container = self.visibleContainer else { + return + } + + container.headerFooter = self.state + } + } + + private(set) var visibleContainer : SupplementaryContainerView? + + func willDisplay(view : SupplementaryContainerView) + { + self.visibleContainer = view + } + + func didEndDisplay() + { + self.visibleContainer = nil + } + + func applyToVisibleView() + { + guard let view = visibleContainer?.content, let state = self.state else { + return + } + + state.applyTo(view: view, reason: .wasUpdated) + } + } + + final class HeaderFooterState : AnyPresentationHeaderFooterState + { + var model : HeaderFooter + + init(_ model : HeaderFooter) + { + self.model = model + } + + // MARK: AnyPresentationHeaderFooterState + + var anyModel: AnyHeaderFooter { + return self.model + } + + func dequeueAndPrepareReusableHeaderFooterView(in cache : ReusableViewCache, frame : CGRect) -> UIView + { + let view = cache.pop(with: self.model.reuseIdentifier) { + return Element.createReusableHeaderFooterView(frame: frame) + } + + self.applyTo(view: view, reason: .willDisplay) + + return view + } + + func enqueueReusableHeaderFooterView(_ view : UIView, in cache : ReusableViewCache) + { + cache.push(view, with: self.model.reuseIdentifier) + } + + func createReusableHeaderFooterView(frame : CGRect) -> UIView + { + return Element.createReusableHeaderFooterView(frame: frame) + } + + func applyTo(view : UIView, reason : ApplyReason) + { + let view = view as! Element.ContentView + + self.model.element.apply(to: view, reason: reason) + } + + func setNew(headerFooter anyHeaderFooter: AnyHeaderFooter) + { + let oldModel = self.model + + self.model = anyHeaderFooter as! HeaderFooter + + let isEquivalent = self.model.anyIsEquivalent(to: oldModel) + + if isEquivalent == false { + self.resetCachedSizes() + } + } + + private var cachedSizes : [SizeKey:CGSize] = [:] + + func resetCachedSizes() + { + self.cachedSizes.removeAll() + } + + func size(in sizeConstraint : CGSize, layoutDirection : LayoutDirection, defaultSize : CGSize, measurementCache : ReusableViewCache) -> CGSize + { + guard sizeConstraint.isEmpty == false else { + return .zero + } + + let key = SizeKey( + width: sizeConstraint.width, + height: sizeConstraint.height, + layoutDirection: layoutDirection, + sizing: self.model.sizing + ) + + if let size = self.cachedSizes[key] { + return size + } else { + SignpostLogger.log(.begin, log: .updateContent, name: "Measure HeaderFooter", for: self.model) + + let size : CGSize = measurementCache.use( + with: self.model.reuseIdentifier, + create: { + return Element.createReusableHeaderFooterView(frame: .zero) + }, { view in + self.model.element.apply(to: view, reason: .willDisplay) + + return self.model.sizing.measure(with: view, in: sizeConstraint, layoutDirection: layoutDirection, defaultSize: defaultSize) + }) + + self.cachedSizes[key] = size + + SignpostLogger.log(.end, log: .updateContent, name: "Measure HeaderFooter", for: self.model) + + return size + } + } + } +} diff --git a/Listable/Sources/Internal/Presentation State/PresentationState.ItemState.swift b/Listable/Sources/Internal/Presentation State/PresentationState.ItemState.swift new file mode 100644 index 000000000..3c004842e --- /dev/null +++ b/Listable/Sources/Internal/Presentation State/PresentationState.ItemState.swift @@ -0,0 +1,395 @@ +// +// PresentationState.ItemState.swift +// Listable +// +// Created by Kyle Van Essen on 5/22/20. +// + +import Foundation + + +protocol AnyPresentationItemState : AnyObject +{ + var isDisplayed : Bool { get } + func setAndPerform(isDisplayed: Bool) + + var itemPosition : ItemPosition { get set } + + var anyModel : AnyItem { get } + + var reorderingActions : ReorderingActions { get } + + var cellRegistrationInfo : (class:AnyClass, reuseIdentifier:String) { get } + + func dequeueAndPrepareCollectionViewCell(in collectionView : UICollectionView, for indexPath : IndexPath) -> UICollectionViewCell + + func applyTo(cell anyCell : UICollectionViewCell, itemState : Listable.ItemState, reason : ApplyReason) + func applyToVisibleCell() + + func setNew(item anyItem : AnyItem, reason : PresentationState.ItemUpdateReason) + + func willDisplay(cell : UICollectionViewCell, in collectionView : UICollectionView, for indexPath : IndexPath) + func didEndDisplay() + + func wasRemoved() + + var isSelected : Bool { get } + func performUserDidSelectItem(isSelected: Bool) + + func resetCachedSizes() + func size(in sizeConstraint : CGSize, layoutDirection : LayoutDirection, defaultSize : CGSize, measurementCache : ReusableViewCache) -> CGSize + + func moved(with result : Reordering.Result) +} + + +protocol ItemElementCoordinatorDelegate : AnyObject +{ + func coordinatorUpdated(for item : AnyItem) +} + + +public struct ItemStateDependencies +{ + internal var reorderingDelegate : ReorderingActionsDelegate + internal var coordinatorDelegate : ItemElementCoordinatorDelegate +} + + +extension PresentationState +{ + enum ItemUpdateReason : CaseIterable + { + case move + case updateFromList + case updateFromItemCoordinator + case noChange + } + + final class ItemState : AnyPresentationItemState + { + var model : Item { + self.storage.model + } + + private(set) var coordination : Coordination + + struct Coordination { + var coordinator : Element.Coordinator + + let actions : ItemElementCoordinatorActions + let info : ItemElementCoordinatorInfo + } + + let reorderingActions: ReorderingActions + + var itemPosition : ItemPosition + + let storage : Storage + + init(with model : Item, dependencies : ItemStateDependencies) + { + self.reorderingActions = ReorderingActions() + self.itemPosition = .single + + self.cellRegistrationInfo = (ItemElementCell.self, model.reuseIdentifier.stringValue) + + let storage = Storage(model) + self.storage = storage + + let actions = ItemElementCoordinatorActions( + current: { storage.model }, + update: { + + /// This is a temporary update callback, in case the initialization of the + /// coordinator causes an update to the item itself. + + storage.model = $0 + } + ) + + let info = ItemElementCoordinatorInfo( + original: storage.model, + current: { storage.model } + ) + + let coordinator = model.element.makeCoordinator(actions: actions, info: info) + + self.coordination = Coordination( + coordinator: coordinator, + actions: actions, + info: info + ) + + self.reorderingActions.item = self + self.reorderingActions.delegate = dependencies.reorderingDelegate + + /// Now that the presentation state is entirely configured, set up the final + /// update callback, which triggers a `setNew` call, alongside informing the + /// `listView` that changes have occurred. + + weak var coordinatorDelegate = dependencies.coordinatorDelegate + + self.coordination.actions.updateCallback = { [weak coordinatorDelegate, weak self] new in + guard let self = self, let delegate = coordinatorDelegate else { + return + } + + self.setNew(item: new, reason: .updateFromItemCoordinator) + + delegate.coordinatorUpdated(for: self.anyModel) + } + + self.storage.didSetState = { [weak self] old, new in + self?.stateWasUpdated(old: old, new: new) + } + + self.coordination.coordinator.wasCreated() + } + + // MARK: AnyPresentationItemState + + private(set) var isDisplayed : Bool = false + + func setAndPerform(isDisplayed: Bool) { + guard self.isDisplayed != isDisplayed else { + return + } + + self.isDisplayed = isDisplayed + + if self.isDisplayed { + self.model.onDisplay?(self.model.element) + } else { + self.model.onEndDisplay?(self.model.element) + } + } + + var anyModel : AnyItem { + return self.model + } + + var cellRegistrationInfo : (class:AnyClass, reuseIdentifier:String) + + func dequeueAndPrepareCollectionViewCell(in collectionView : UICollectionView, for indexPath : IndexPath) -> UICollectionViewCell + { + let anyCell = collectionView.dequeueReusableCell(withReuseIdentifier: self.cellRegistrationInfo.reuseIdentifier, for: indexPath) + + let cell = anyCell as! ItemElementCell + + // Theme cell & apply content. + + let itemState = Listable.ItemState(cell: cell) + + self.applyTo( + cell: cell, + itemState: itemState, + reason: .willDisplay + ) + + return cell + } + + func applyTo(cell anyCell : UICollectionViewCell, itemState : Listable.ItemState, reason : ApplyReason) + { + let cell = anyCell as! ItemElementCell + + let applyInfo = ApplyItemElementInfo( + state: itemState, + position: self.itemPosition, + reordering: self.reorderingActions + ) + + // Apply Model State + + self.model.element.apply( + to: ItemElementViews(content: cell.contentContainer.contentView, background: cell.background, selectedBackground: cell.selectedBackground), + for: reason, + with: applyInfo + ) + + // Apply Swipe To Action Appearance + if let actions = self.model.swipeActions { + cell.contentContainer.registerSwipeActionsIfNeeded(actions: actions, reason: reason) + } else { + cell.contentContainer.deregisterSwipeIfNeeded() + } + } + + func applyToVisibleCell() + { + guard let cell = self.storage.state.visibleCell else { + return + } + + self.applyTo( + cell: cell, + itemState: .init(cell: cell), + reason: .wasUpdated + ) + } + + func setNew(item anyItem: AnyItem, reason: ItemUpdateReason) + { + let old = self.model + let new = anyItem as! Item + + self.storage.model = new + self.storage.state.isSelected = new.selectionStyle.isSelected + + if reason == .updateFromList || reason == .move { + self.coordination.info.original = new + self.coordination.coordinator.wasUpdated(old: old, new: new) + } + + if reason != .noChange { + self.resetCachedSizes() + } + } + + func willDisplay(cell anyCell : UICollectionViewCell, in collectionView : UICollectionView, for indexPath : IndexPath) + { + let cell = (anyCell as! ItemElementCell) + + self.storage.state.visibleCell = cell + } + + func didEndDisplay() + { + self.storage.state.visibleCell = nil + } + + func wasRemoved() + { + self.coordination.coordinator.wasRemoved() + } + + var isSelected: Bool { + self.storage.state.isSelected + } + + func performUserDidSelectItem(isSelected: Bool) + { + self.storage.state.isSelected = isSelected + + if isSelected { + self.model.onSelect?(self.model.element) + } else { + self.model.onDeselect?(self.model.element) + } + + self.applyToVisibleCell() + } + + func stateWasUpdated(old : State, new : State) + { + let coordinator = self.coordination.coordinator + + if old.isSelected != new.isSelected { + if new.isSelected { + coordinator.wasSelected() + } else { + coordinator.wasDeselected() + } + } + + if old.visibleCell != new.visibleCell { + if let cell = new.visibleCell { + let contentView = cell.contentContainer.contentView + + coordinator.view = contentView + coordinator.willDisplay(with: contentView) + } else { + if let view = old.visibleCell?.contentContainer.contentView { + coordinator.didEndDisplay(with: view) + } + } + } + } + + private var cachedSizes : [SizeKey:CGSize] = [:] + + func resetCachedSizes() + { + self.cachedSizes.removeAll() + } + + func size(in sizeConstraint : CGSize, layoutDirection : LayoutDirection, defaultSize : CGSize, measurementCache : ReusableViewCache) -> CGSize + { + guard sizeConstraint.isEmpty == false else { + return .zero + } + + let key = SizeKey( + width: sizeConstraint.width, + height: sizeConstraint.height, + layoutDirection: layoutDirection, + sizing: self.model.sizing + ) + + if let size = self.cachedSizes[key] { + return size + } else { + SignpostLogger.log(.begin, log: .updateContent, name: "Measure ItemElement", for: self.model) + + let size : CGSize = measurementCache.use( + with: self.model.reuseIdentifier, + create: { + return ItemElementCell() + }, { cell in + let itemState = Listable.ItemState(isSelected: false, isHighlighted: false) + + self.applyTo(cell: cell, itemState: itemState, reason: .willDisplay) + + return self.model.sizing.measure(with: cell, in: sizeConstraint, layoutDirection: layoutDirection, defaultSize: defaultSize) + }) + + self.cachedSizes[key] = size + + SignpostLogger.log(.end, log: .updateContent, name: "Measure ItemElement", for: self.model) + + return size + } + } + + func moved(with result : Reordering.Result) + { + self.model.reordering?.didReorder(result) + } + } +} + + +extension PresentationState.ItemState +{ + final class Storage { + + var didSetState : (State, State) -> () = { _, _ in } + + var model : Item { + willSet { + guard self.model.identifier == newValue.identifier else { + fatalError("Cannot change the identifier of an item while updating it. Changed from '\(self.model.identifier)' to '\(newValue.identifier)'.") + } + } + } + + var state : State { + didSet { + self.didSetState(oldValue, self.state) + } + } + + init(_ model : Item) + { + self.model = model + + self.state = State(isSelected: self.model.selectionStyle.isSelected, visibleCell: nil) + } + } + + internal struct State { + var isSelected : Bool + var visibleCell : ItemElementCell? + } +} diff --git a/Listable/Sources/Internal/Presentation State/PresentationState.SectionState.swift b/Listable/Sources/Internal/Presentation State/PresentationState.SectionState.swift new file mode 100644 index 000000000..4fa4938c3 --- /dev/null +++ b/Listable/Sources/Internal/Presentation State/PresentationState.SectionState.swift @@ -0,0 +1,99 @@ +// +// PresentationState.SectionState.swift +// Listable +// +// Created by Kyle Van Essen on 5/22/20. +// + +import Foundation + + +extension PresentationState +{ + final class SectionState + { + var model : Section + + var header : HeaderFooterViewStatePair = .init() + var footer : HeaderFooterViewStatePair = .init() + + var items : [AnyPresentationItemState] + + init(with model : Section, dependencies : ItemStateDependencies) + { + self.model = model + + self.header.state = SectionState.headerFooterState(with: self.header.state, new: model.header) + self.footer.state = SectionState.headerFooterState(with: self.footer.state, new: model.footer) + + self.items = self.model.items.map { + $0.newPresentationItemState(with: dependencies) as! AnyPresentationItemState + } + } + + func removeItem(at index : Int) + { + self.model.items.remove(at: index) + self.items.remove(at: index) + } + + func insert(item : AnyPresentationItemState, at index : Int) + { + self.model.items.insert(item.anyModel, at: index) + self.items.insert(item, at: index) + } + + func update( + with oldSection : Section, + new newSection : Section, + changes : SectionedDiff.ItemChanges, + dependencies : ItemStateDependencies + ) + { + self.model = newSection + + self.header.state = SectionState.headerFooterState(with: self.header.state, new: self.model.header) + self.footer.state = SectionState.headerFooterState(with: self.footer.state, new: self.model.footer) + + self.items = changes.transform( + old: self.items, + removed: { _, item in item.wasRemoved() }, + added: { $0.newPresentationItemState(with: dependencies) as! AnyPresentationItemState }, + moved: { old, new, item in item.setNew(item: new, reason: .move) }, + updated: { old, new, item in item.setNew(item: new, reason: .updateFromList) }, + noChange: { old, new, item in item.setNew(item: new, reason: .noChange) } + ) + } + + func wasRemoved() + { + for item in self.items { + item.wasRemoved() + } + } + + static func headerFooterState(with current : AnyPresentationHeaderFooterState?, new : AnyHeaderFooter?) -> AnyPresentationHeaderFooterState? + { + if let current = current { + if let new = new { + let isSameType = type(of: current.anyModel) == type(of: new) + + if isSameType { + current.setNew(headerFooter: new) + return current + } else { + return (new.newPresentationHeaderFooterState() as! AnyPresentationHeaderFooterState) + } + } else { + return nil + } + } else { + if let new = new { + return (new.newPresentationHeaderFooterState() as! AnyPresentationHeaderFooterState) + } else { + return nil + } + } + } + } +} diff --git a/Listable/Sources/Internal/Presentation State/PresentationState.swift b/Listable/Sources/Internal/Presentation State/PresentationState.swift new file mode 100644 index 000000000..416e6bc2e --- /dev/null +++ b/Listable/Sources/Internal/Presentation State/PresentationState.swift @@ -0,0 +1,253 @@ +// +// PresentationState.swift +// Listable +// +// Created by Kyle Van Essen on 7/22/19. +// + + +/// A class used to manage the "live" / mutable state of the visible items in the list, +/// which is persistent across diffs of content (instances are only created or destroyed when an item enters or leaves the list). +final class PresentationState +{ + // + // MARK: Public Properties + // + + var refreshControl : RefreshControl.PresentationState? + + var header : HeaderFooterViewStatePair = .init() + var footer : HeaderFooterViewStatePair = .init() + var overscrollFooter : HeaderFooterViewStatePair = .init() + + var sections : [PresentationState.SectionState] + + private(set) var containsAllItems : Bool + + private(set) var contentIdentifier : AnyHashable? + + // + // MARK: Initialization + // + + init() + { + self.refreshControl = nil + self.sections = [] + + self.containsAllItems = true + + self.contentIdentifier = nil + } + + // + // MARK: Accessing Data + // + + var sectionModels : [Section] { + return self.sections.map { section in + var sectionModel = section.model + + sectionModel.items = section.items.map { + $0.anyModel + } + + return sectionModel + } + } + + var selectedIndexPaths : [IndexPath] { + let indexes : [[IndexPath]] = self.sections.compactMapWithIndex { sectionIndex, _, section in + return section.items.compactMapWithIndex { itemIndex, _, item in + if item.isSelected { + return IndexPath(item: itemIndex, section: sectionIndex) + } else { + return nil + } + } + } + + return indexes.flatMap { $0 } + } + + func item(at indexPath : IndexPath) -> AnyPresentationItemState + { + let section = self.sections[indexPath.section] + let item = section.items[indexPath.item] + + return item + } + + func sections(at indexes : [Int]) -> [SectionState] + { + var sections : [SectionState] = [] + + indexes.forEach { + sections.append(self.sections[$0]) + } + + return sections + } + + public var lastIndexPath : IndexPath? + { + let nonEmptySections : [(index:Int, section:SectionState)] = self.sections.compactMapWithIndex { index, _, state in + return state.items.isEmpty ? nil : (index, state) + } + + guard let lastSection = nonEmptySections.last else { + return nil + } + + return IndexPath(item: (lastSection.section.items.count - 1), section: lastSection.index) + } + + internal func indexPath(for itemToFind : AnyPresentationItemState) -> IndexPath? + { + for (sectionIndex, section) in self.sections.enumerated() { + for (itemIndex, item) in section.items.enumerated() { + if item === itemToFind { + return IndexPath(item: itemIndex, section: sectionIndex) + } + } + } + + return nil + } + + internal func forEachItem(_ block : (IndexPath, AnyPresentationItemState) -> ()) + { + self.sections.forEachWithIndex { sectionIndex, _, section in + section.items.forEachWithIndex { itemIndex, _, item in + block(IndexPath(item: itemIndex, section: sectionIndex), item) + } + } + } + + // + // MARK: Mutating Data + // + + func moveItem(from : IndexPath, to : IndexPath) + { + guard from != to else { + return + } + + let item = self.item(at: from) + + self.remove(at: from) + self.insert(item: item, at: to) + } + + @discardableResult + func remove(at indexPath : IndexPath) -> AnyPresentationItemState + { + return self.sections[indexPath.section].items.remove(at: indexPath.item) + } + + func remove(item itemToRemove : AnyPresentationItemState) -> IndexPath? + { + guard let indexPath = self.indexPath(for: itemToRemove) else { + return nil + } + + self.sections[indexPath.section].removeItem(at: indexPath.item) + + return indexPath + } + + func insert(item : AnyPresentationItemState, at indexPath : IndexPath) + { + self.sections[indexPath.section].insert(item: item, at: indexPath.item) + } + + // + // MARK: Height Caching + // + + func resetAllCachedSizes() + { + self.sections.forEach { section in + section.items.forEach { item in + item.resetCachedSizes() + } + } + } + + // + // MARK: Updating Content & State + // + + func update(with diff : SectionedDiff, slice : Content.Slice, dependencies: ItemStateDependencies, loggable : SignpostLoggable?) + { + SignpostLogger.log(.begin, log: .updateContent, name: "Update Presentation State", for: loggable) + + defer { + SignpostLogger.log(.end, log: .updateContent, name: "Update Presentation State", for: loggable) + } + + self.containsAllItems = slice.containsAllItems + + self.contentIdentifier = slice.content.identifier + + self.header.state = SectionState.headerFooterState(with: self.header.state, new: slice.content.header) + self.footer.state = SectionState.headerFooterState(with: self.footer.state, new: slice.content.footer) + self.overscrollFooter.state = SectionState.headerFooterState(with: self.overscrollFooter.state, new: slice.content.overscrollFooter) + + self.sections = diff.changes.transform( + old: self.sections, + removed: { _, section in section.wasRemoved() }, + added: { section in SectionState(with: section, dependencies: dependencies) }, + moved: { old, new, changes, section in section.update(with: old, new: new, changes: changes, dependencies: dependencies) }, + noChange: { old, new, changes, section in section.update(with: old, new: new, changes: changes, dependencies: dependencies) } + ) + } + + internal func updateRefreshControl(with new : RefreshControl?, in view : UIScrollView) + { + if let existing = self.refreshControl, let new = new { + existing.update(with: new) + } else if self.refreshControl == nil, let new = new { + let newControl = RefreshControl.PresentationState(new) + view.refreshControl = newControl.view + self.refreshControl = newControl + } else if self.refreshControl != nil, new == nil { + view.refreshControl = nil + self.refreshControl = nil + } + } + + // + // MARK: Cell & Supplementary View Registration + // + + private var registeredCellObjectIdentifiers : Set = Set() + + func registerCell(for item : AnyPresentationItemState, in view : UICollectionView) + { + let info = item.cellRegistrationInfo + + let identifier = ObjectIdentifier(info.class) + + guard self.registeredCellObjectIdentifiers.contains(identifier) == false else { + return + } + + self.registeredCellObjectIdentifiers.insert(identifier) + + view.register(info.class, forCellWithReuseIdentifier: info.reuseIdentifier) + } +} + + +extension PresentationState +{ + struct SizeKey : Hashable + { + var width : CGFloat + var height : CGFloat + var layoutDirection : LayoutDirection + var sizing : Sizing + } +} diff --git a/Listable/Sources/Internal/PresentationState.swift b/Listable/Sources/Internal/PresentationState.swift deleted file mode 100644 index eefe13188..000000000 --- a/Listable/Sources/Internal/PresentationState.swift +++ /dev/null @@ -1,768 +0,0 @@ -// -// PresentationState.swift -// Listable -// -// Created by Kyle Van Essen on 7/22/19. -// - - -protocol AnyPresentationItemState : AnyObject -{ - var isDisplayed : Bool { get } - func setAndPerform(isDisplayed: Bool) - - var itemPosition : ItemPosition { get set } - - var anyModel : AnyItem { get } - - var reorderingActions : ReorderingActions { get } - - var cellRegistrationInfo : (class:AnyClass, reuseIdentifier:String) { get } - - func dequeueAndPrepareCollectionViewCell(in collectionView : UICollectionView, for indexPath : IndexPath) -> UICollectionViewCell - - func applyTo(cell anyCell : UICollectionViewCell, itemState : Listable.ItemState, reason : ApplyReason) - func applyToVisibleCell() - - func setNew(item anyItem : AnyItem, reason : UpdateReason) - - func willDisplay(cell : UICollectionViewCell, in collectionView : UICollectionView, for indexPath : IndexPath) - func didEndDisplay() - - var isSelected : Bool { get } - func performUserDidSelectItem(isSelected: Bool) - - func resetCachedSizes() - func size(in sizeConstraint : CGSize, layoutDirection : LayoutDirection, defaultSize : CGSize, measurementCache : ReusableViewCache) -> CGSize - - func moved(with result : Reordering.Result) -} - - -protocol AnyPresentationHeaderFooterState : AnyObject -{ - var anyModel : AnyHeaderFooter { get } - - func dequeueAndPrepareReusableHeaderFooterView(in cache : ReusableViewCache, frame : CGRect) -> UIView - func enqueueReusableHeaderFooterView(_ view : UIView, in cache : ReusableViewCache) - - func applyTo(view anyView : UIView, reason : ApplyReason) - - func setNew(headerFooter anyHeaderFooter : AnyHeaderFooter) - - func resetCachedSizes() - func size(in sizeConstraint : CGSize, layoutDirection : LayoutDirection, defaultSize : CGSize, measurementCache : ReusableViewCache) -> CGSize -} - - -enum UpdateReason -{ - case move - case update - case noChange -} - - -/* - A class used to manage the "live" / mutable state of the visible items in the collection. - which is persistent across diffs of content (instances are only created or destroyed when an item enters or leaves the table). - - This is where bindings or other update-driving objects live, - which then push the changes to the item and section content back into view models. - */ -final class PresentationState -{ - // - // MARK: Public Properties - // - - unowned var view : ListView! - - var refreshControl : RefreshControl.PresentationState? - - var header : HeaderFooterViewStatePair = .init() - var footer : HeaderFooterViewStatePair = .init() - var overscrollFooter : HeaderFooterViewStatePair = .init() - - var sections : [PresentationState.SectionState] - - private(set) var containsAllItems : Bool - - private(set) var contentIdentifier : AnyHashable? - - // - // MARK: Initialization - // - - init() - { - self.refreshControl = nil - self.sections = [] - - self.containsAllItems = true - - self.contentIdentifier = nil - } - - // - // MARK: Accessing Data - // - - var sectionModels : [Section] { - return self.sections.map { section in - var sectionModel = section.model - - sectionModel.items = section.items.map { - $0.anyModel - } - - return sectionModel - } - } - - var selectedIndexPaths : [IndexPath] { - let indexes : [[IndexPath]] = self.sections.compactMapWithIndex { sectionIndex, _, section in - return section.items.compactMapWithIndex { itemIndex, _, item in - if item.isSelected { - return IndexPath(item: itemIndex, section: sectionIndex) - } else { - return nil - } - } - } - - return indexes.flatMap { $0 } - } - - func item(at indexPath : IndexPath) -> AnyPresentationItemState - { - let section = self.sections[indexPath.section] - let item = section.items[indexPath.item] - - return item - } - - func sections(at indexes : [Int]) -> [SectionState] - { - var sections : [SectionState] = [] - - indexes.forEach { - sections.append(self.sections[$0]) - } - - return sections - } - - public var lastIndexPath : IndexPath? - { - let nonEmptySections : [(index:Int, section:SectionState)] = self.sections.compactMapWithIndex { index, _, state in - return state.items.isEmpty ? nil : (index, state) - } - - guard let lastSection = nonEmptySections.last else { - return nil - } - - return IndexPath(item: (lastSection.section.items.count - 1), section: lastSection.index) - } - - internal func indexPath(for itemToFind : AnyPresentationItemState) -> IndexPath? - { - for (sectionIndex, section) in self.sections.enumerated() { - for (itemIndex, item) in section.items.enumerated() { - if item === itemToFind { - return IndexPath(item: itemIndex, section: sectionIndex) - } - } - } - - return nil - } - - internal func forEachItem(_ block : (IndexPath, AnyPresentationItemState) -> ()) - { - self.sections.forEachWithIndex { sectionIndex, _, section in - section.items.forEachWithIndex { itemIndex, _, item in - block(IndexPath(item: itemIndex, section: sectionIndex), item) - } - } - } - - // - // MARK: Mutating Data - // - - func moveItem(from : IndexPath, to : IndexPath) - { - guard from != to else { - return - } - - let item = self.item(at: from) - - self.remove(at: from) - self.insert(item: item, at: to) - } - - @discardableResult - func remove(at indexPath : IndexPath) -> AnyPresentationItemState - { - return self.sections[indexPath.section].items.remove(at: indexPath.item) - } - - func remove(item itemToRemove : AnyPresentationItemState) -> IndexPath? - { - guard let indexPath = self.indexPath(for: itemToRemove) else { - return nil - } - - self.sections[indexPath.section].removeItem(at: indexPath.item) - - return indexPath - } - - func insert(item : AnyPresentationItemState, at indexPath : IndexPath) - { - self.sections[indexPath.section].insert(item: item, at: indexPath.item) - } - - // - // MARK: Height Caching - // - - func resetAllCachedSizes() - { - self.sections.forEach { section in - section.items.forEach { item in - item.resetCachedSizes() - } - } - } - - // - // MARK: Updating Content & State - // - - func update(with diff : SectionedDiff, slice : Content.Slice) - { - SignpostLogger.log(.begin, log: .updateContent, name: "Update Presentation State", for: self.view) - - defer { - SignpostLogger.log(.end, log: .updateContent, name: "Update Presentation State", for: self.view) - } - - self.containsAllItems = slice.containsAllItems - - self.contentIdentifier = slice.content.identifier - - self.header.state = SectionState.headerFooterState(with: self.header.state, new: slice.content.header) - self.footer.state = SectionState.headerFooterState(with: self.footer.state, new: slice.content.footer) - self.overscrollFooter.state = SectionState.headerFooterState(with: self.overscrollFooter.state, new: slice.content.overscrollFooter) - - self.sections = diff.changes.transform( - old: self.sections, - removed: { _, _ in }, - added: { section in SectionState(with: section, listView: self.view) }, - moved: { old, new, changes, section in section.update(with: old, new: new, changes: changes, listView: self.view) }, - noChange: { old, new, changes, section in section.update(with: old, new: new, changes: changes, listView: self.view) } - ) - } - - internal func updateRefreshControl(with new : RefreshControl?) - { - if let existing = self.refreshControl, let new = new { - existing.update(with: new) - } else if self.refreshControl == nil, let new = new { - let newControl = RefreshControl.PresentationState(new) - self.view.collectionView.refreshControl = newControl.view - self.refreshControl = newControl - } else if self.refreshControl != nil, new == nil { - self.view.collectionView.refreshControl = nil - self.refreshControl = nil - } - } - - // - // MARK: Cell & Supplementary View Registration - // - - private var registeredCellObjectIdentifiers : Set = Set() - - func registerCell(for item : AnyPresentationItemState) - { - let info = item.cellRegistrationInfo - - let identifier = ObjectIdentifier(info.class) - - guard self.registeredCellObjectIdentifiers.contains(identifier) == false else { - return - } - - self.registeredCellObjectIdentifiers.insert(identifier) - - self.view.collectionView.register(info.class, forCellWithReuseIdentifier: info.reuseIdentifier) - } - - final class SectionState - { - var model : Section - - var header : HeaderFooterViewStatePair = .init() - var footer : HeaderFooterViewStatePair = .init() - - var items : [AnyPresentationItemState] - - init(with model : Section, listView : ListView) - { - self.model = model - - self.header.state = SectionState.headerFooterState(with: self.header.state, new: model.header) - self.footer.state = SectionState.headerFooterState(with: self.footer.state, new: model.footer) - - self.items = self.model.items.map { - $0.newPresentationItemState(in: listView) as! AnyPresentationItemState - } - } - - fileprivate func removeItem(at index : Int) - { - self.model.items.remove(at: index) - self.items.remove(at: index) - } - - fileprivate func insert(item : AnyPresentationItemState, at index : Int) - { - self.model.items.insert(item.anyModel, at: index) - self.items.insert(item, at: index) - } - - fileprivate func update( - with oldSection : Section, - new newSection : Section, - changes : SectionedDiff.ItemChanges, - listView : ListView - ) - { - self.model = newSection - - self.header.state = SectionState.headerFooterState(with: self.header.state, new: self.model.header) - self.footer.state = SectionState.headerFooterState(with: self.footer.state, new: self.model.footer) - - self.items = changes.transform( - old: self.items, - removed: { _, _ in }, - added: { $0.newPresentationItemState(in: listView) as! AnyPresentationItemState }, - moved: { old, new, item in item.setNew(item: new, reason: .move) }, - updated: { old, new, item in item.setNew(item: new, reason: .update) }, - noChange: { old, new, item in item.setNew(item: new, reason: .noChange) } - ) - } - - fileprivate static func headerFooterState(with current : AnyPresentationHeaderFooterState?, new : AnyHeaderFooter?) -> AnyPresentationHeaderFooterState? - { - if let current = current { - if let new = new { - let isSameType = type(of: current.anyModel) == type(of: new) - - if isSameType { - current.setNew(headerFooter: new) - return current - } else { - return (new.newPresentationHeaderFooterState() as! AnyPresentationHeaderFooterState) - } - } else { - return nil - } - } else { - if let new = new { - return (new.newPresentationHeaderFooterState() as! AnyPresentationHeaderFooterState) - } else { - return nil - } - } - } - } - - final class HeaderFooterViewStatePair - { - var state : AnyPresentationHeaderFooterState? { - didSet { - guard oldValue !== self.state else { - return - } - - guard let container = self.visibleContainer else { - return - } - - container.headerFooter = self.state - } - } - - private(set) var visibleContainer : SupplementaryContainerView? - - func willDisplay(view : SupplementaryContainerView) - { - self.visibleContainer = view - } - - func didEndDisplay() - { - self.visibleContainer = nil - } - - func applyToVisibleView() - { - guard let view = visibleContainer?.content, let state = self.state else { - return - } - - state.applyTo(view: view, reason: .wasUpdated) - } - } - - final class HeaderFooterState : AnyPresentationHeaderFooterState - { - var model : HeaderFooter - - init(_ model : HeaderFooter) - { - self.model = model - } - - // MARK: AnyPresentationHeaderFooterState - - var anyModel: AnyHeaderFooter { - return self.model - } - - func dequeueAndPrepareReusableHeaderFooterView(in cache : ReusableViewCache, frame : CGRect) -> UIView - { - let view = cache.pop(with: self.model.reuseIdentifier) { - return Element.createReusableHeaderFooterView(frame: frame) - } - - self.applyTo(view: view, reason: .willDisplay) - - return view - } - - func enqueueReusableHeaderFooterView(_ view : UIView, in cache : ReusableViewCache) - { - cache.push(view, with: self.model.reuseIdentifier) - } - - func createReusableHeaderFooterView(frame : CGRect) -> UIView - { - return Element.createReusableHeaderFooterView(frame: frame) - } - - func applyTo(view : UIView, reason : ApplyReason) - { - let view = view as! Element.ContentView - - self.model.element.apply(to: view, reason: reason) - } - - func setNew(headerFooter anyHeaderFooter: AnyHeaderFooter) - { - let oldModel = self.model - - self.model = anyHeaderFooter as! HeaderFooter - - let isEquivalent = self.model.anyIsEquivalent(to: oldModel) - - if isEquivalent == false { - self.resetCachedSizes() - } - } - - private var cachedSizes : [SizeKey:CGSize] = [:] - - func resetCachedSizes() - { - self.cachedSizes.removeAll() - } - - func size(in sizeConstraint : CGSize, layoutDirection : LayoutDirection, defaultSize : CGSize, measurementCache : ReusableViewCache) -> CGSize - { - guard sizeConstraint.isEmpty == false else { - return .zero - } - - let key = SizeKey( - width: sizeConstraint.width, - height: sizeConstraint.height, - layoutDirection: layoutDirection, - sizing: self.model.sizing - ) - - if let size = self.cachedSizes[key] { - return size - } else { - SignpostLogger.log(.begin, log: .updateContent, name: "Measure HeaderFooter", for: self.model) - - let size : CGSize = measurementCache.use( - with: self.model.reuseIdentifier, - create: { - return Element.createReusableHeaderFooterView(frame: .zero) - }, { view in - self.model.element.apply(to: view, reason: .willDisplay) - - return self.model.sizing.measure(with: view, in: sizeConstraint, layoutDirection: layoutDirection, defaultSize: defaultSize) - }) - - self.cachedSizes[key] = size - - SignpostLogger.log(.end, log: .updateContent, name: "Measure HeaderFooter", for: self.model) - - return size - } - } - } - - final class ItemState : AnyPresentationItemState - { - var model : Item - - let binding : Binding? - - let reorderingActions: ReorderingActions - - var itemPosition : ItemPosition - - private var visibleCell : ItemElementCell? - - init(with model : Item, listView : ListView) - { - self.model = model - - self.reorderingActions = ReorderingActions() - self.itemPosition = .single - - self.cellRegistrationInfo = (ItemElementCell.self, model.reuseIdentifier.stringValue) - - self.isSelected = model.selectionStyle.isSelected - - if let binding = self.model.bind?(self.model.element) - { - self.binding = binding - - binding.start() - - binding.onChange { [weak self] element in - guard let self = self else { return } - - self.model.element = element - - if let cell = self.visibleCell { - let applyInfo = ApplyItemElementInfo( - state: .init(cell: cell), - position: self.itemPosition, - reordering: self.reorderingActions - ) - - self.model.element.apply( - to: ItemElementViews(content: cell.contentContainer.contentView, background: cell.background, selectedBackground: cell.selectedBackground), - for: .wasUpdated, - with: applyInfo - ) - } - } - - // Pull the current element off the binding in case it changed - // during initialization, from the provider. - - self.model.element = binding.element - } else { - self.binding = nil - } - - self.reorderingActions.item = self - self.reorderingActions.listView = listView - } - - deinit { - self.binding?.discard() - } - - // MARK: AnyPresentationItemState - - private(set) var isDisplayed : Bool = false - - func setAndPerform(isDisplayed: Bool) { - guard self.isDisplayed != isDisplayed else { - return - } - - self.isDisplayed = isDisplayed - - if self.isDisplayed { - self.model.onDisplay?(self.model.element) - } else { - self.model.onEndDisplay?(self.model.element) - } - } - - var anyModel : AnyItem { - return self.model - } - - var cellRegistrationInfo : (class:AnyClass, reuseIdentifier:String) - - func dequeueAndPrepareCollectionViewCell(in collectionView : UICollectionView, for indexPath : IndexPath) -> UICollectionViewCell - { - let anyCell = collectionView.dequeueReusableCell(withReuseIdentifier: self.cellRegistrationInfo.reuseIdentifier, for: indexPath) - - let cell = anyCell as! ItemElementCell - - // Theme cell & apply content. - - let itemState = Listable.ItemState(cell: cell) - - self.applyTo( - cell: cell, - itemState: itemState, - reason: .willDisplay - ) - - return cell - } - - func applyTo(cell anyCell : UICollectionViewCell, itemState : Listable.ItemState, reason : ApplyReason) - { - let cell = anyCell as! ItemElementCell - - let applyInfo = ApplyItemElementInfo( - state: itemState, - position: self.itemPosition, - reordering: self.reorderingActions - ) - - // Apply Model State - - self.model.element.apply( - to: ItemElementViews(content: cell.contentContainer.contentView, background: cell.background, selectedBackground: cell.selectedBackground), - for: reason, - with: applyInfo - ) - - // Apply Swipe To Action Appearance - if let actions = self.model.swipeActions { - cell.contentContainer.registerSwipeActionsIfNeeded(actions: actions, reason: reason) - } else { - cell.contentContainer.deregisterSwipeIfNeeded() - } - } - - func applyToVisibleCell() - { - guard let cell = self.visibleCell else { - return - } - - self.applyTo( - cell: cell, - itemState: .init(cell: cell), - reason: .wasUpdated - ) - } - - func setNew(item anyItem: AnyItem, reason: UpdateReason) - { - self.model = anyItem as! Item - - self.isSelected = self.model.selectionStyle.isSelected - - if reason != .noChange { - self.resetCachedSizes() - } - } - - func willDisplay(cell anyCell : UICollectionViewCell, in collectionView : UICollectionView, for indexPath : IndexPath) - { - let cell = (anyCell as! ItemElementCell) - - self.visibleCell = cell - } - - func didEndDisplay() - { - self.visibleCell = nil - } - - var isSelected: Bool - - public func performUserDidSelectItem(isSelected: Bool) - { - self.isSelected = isSelected - - if isSelected { - self.model.onSelect?(self.model.element) - } else { - self.model.onDeselect?(self.model.element) - } - - self.applyToVisibleCell() - } - - private var cachedSizes : [SizeKey:CGSize] = [:] - - func resetCachedSizes() - { - self.cachedSizes.removeAll() - } - - func size(in sizeConstraint : CGSize, layoutDirection : LayoutDirection, defaultSize : CGSize, measurementCache : ReusableViewCache) -> CGSize - { - guard sizeConstraint.isEmpty == false else { - return .zero - } - - let key = SizeKey( - width: sizeConstraint.width, - height: sizeConstraint.height, - layoutDirection: layoutDirection, - sizing: self.model.sizing - ) - - if let size = self.cachedSizes[key] { - return size - } else { - SignpostLogger.log(.begin, log: .updateContent, name: "Measure ItemElement", for: self.model) - - let size : CGSize = measurementCache.use( - with: self.model.reuseIdentifier, - create: { - return ItemElementCell() - }, { cell in - let itemState = Listable.ItemState(isSelected: false, isHighlighted: false) - - self.applyTo(cell: cell, itemState: itemState, reason: .willDisplay) - - return self.model.sizing.measure(with: cell, in: sizeConstraint, layoutDirection: layoutDirection, defaultSize: defaultSize) - }) - - self.cachedSizes[key] = size - - SignpostLogger.log(.end, log: .updateContent, name: "Measure ItemElement", for: self.model) - - return size - } - } - - func moved(with result : Reordering.Result) - { - self.model.reordering?.didReorder(result) - } - } -} - -fileprivate struct SizeKey : Hashable -{ - var width : CGFloat - var height : CGFloat - var layoutDirection : LayoutDirection - var sizing : Sizing -} - diff --git a/Listable/Sources/Item.swift b/Listable/Sources/Item.swift index d26c9ec85..2543c2038 100644 --- a/Listable/Sources/Item.swift +++ b/Listable/Sources/Item.swift @@ -20,12 +20,12 @@ public protocol AnyItem : AnyItem_Internal { var identifier : AnyIdentifier { get } + var sizing : Sizing { get set } var layout : ItemLayout { get set } var selectionStyle : ItemSelectionStyle { get set } + var swipeActions : SwipeActionsConfiguration? { get set } var reordering : Reordering? { get set } - - func elementEqual(to other : AnyItem) -> Bool } @@ -34,7 +34,7 @@ public protocol AnyItem_Internal func anyWasMoved(comparedTo other : AnyItem) -> Bool func anyIsEquivalent(to other : AnyItem) -> Bool - func newPresentationItemState(in listView : ListView) -> Any + func newPresentationItemState(with dependencies : ItemStateDependencies) -> Any } @@ -67,9 +67,6 @@ public struct Item : AnyItem internal let reuseIdentifier : ReuseIdentifier - public typealias CreateBinding = (Element) -> Binding - internal let bind : CreateBinding? - public var debuggingIdentifier : String? = nil // @@ -90,12 +87,11 @@ public struct Item : AnyItem public init( _ element : Element, - sizing : Sizing = .thatFitsWith(.init(.atLeast(.default))), - layout : ItemLayout = ItemLayout(), - selectionStyle : ItemSelectionStyle = .none, + sizing : Sizing? = nil, + layout : ItemLayout? = nil, + selectionStyle : ItemSelectionStyle? = nil, swipeActions : SwipeActionsConfiguration? = nil, reordering : Reordering? = nil, - bind : CreateBinding? = nil, onDisplay : OnDisplay? = nil, onEndDisplay : OnEndDisplay? = nil, onSelect : OnSelect? = nil, @@ -103,18 +99,41 @@ public struct Item : AnyItem ) { self.element = element + + if let sizing = sizing { + self.sizing = sizing + } else if let sizing = element.defaultItemProperties.sizing { + self.sizing = sizing + } else { + self.sizing = .thatFitsWith(.init(.atLeast(.default))) + } - self.sizing = sizing - self.layout = layout - - self.selectionStyle = selectionStyle + if let layout = layout { + self.layout = layout + } else if let layout = element.defaultItemProperties.layout { + self.layout = layout + } else { + self.layout = ItemLayout() + } - self.swipeActions = swipeActions + if let selectionStyle = selectionStyle { + self.selectionStyle = selectionStyle + } else if let selectionStyle = element.defaultItemProperties.selectionStyle { + self.selectionStyle = selectionStyle + } else { + self.selectionStyle = .none + } + if let swipeActions = swipeActions { + self.swipeActions = swipeActions + } else if let swipeActions = element.defaultItemProperties.swipeActions { + self.swipeActions = swipeActions + } else { + self.swipeActions = nil + } + self.reordering = reordering - - self.bind = bind - + self.onDisplay = onDisplay self.onEndDisplay = onEndDisplay @@ -126,22 +145,6 @@ public struct Item : AnyItem self.identifier = AnyIdentifier(self.element.identifier) } - // MARK: AnyItem - - public func elementEqual(to other : AnyItem) -> Bool - { - guard let other = other as? Item else { - return false - } - - return self.elementEqual(to: other) - } - - internal func elementEqual(to other : Item) -> Bool - { - return false - } - // MARK: AnyItem_Internal public func anyIsEquivalent(to other : AnyItem) -> Bool @@ -162,9 +165,42 @@ public struct Item : AnyItem return self.element.wasMoved(comparedTo: other.element) } - public func newPresentationItemState(in listView : ListView) -> Any + public func newPresentationItemState(with dependencies : ItemStateDependencies) -> Any { - return PresentationState.ItemState(with: self, listView: listView) + PresentationState.ItemState(with: self, dependencies: dependencies) + } +} + + +/// Allows specifying default properties to apply to an item when it is initialized, +/// if those values are not provided to the initializer. +/// Only non-nil values are used – if you do not want to provide a default value, +/// simply leave the property nil. +/// +/// The order of precedence used when assigning values is: +/// 1) The value passed to the initializer. +/// 2) The value from `ItemProperties` on the contained `ItemElement`, if non-nil. +/// 3) A standard, default value. +/// +public struct DefaultItemProperties +{ + public var sizing : Sizing? + public var layout : ItemLayout? + + public var selectionStyle : ItemSelectionStyle? + + public var swipeActions : SwipeActionsConfiguration? + + public init( + sizing : Sizing? = nil, + layout : ItemLayout? = nil, + selectionStyle : ItemSelectionStyle? = nil, + swipeActions : SwipeActionsConfiguration? = nil + ) { + self.sizing = sizing + self.layout = layout + self.selectionStyle = selectionStyle + self.swipeActions = swipeActions } } @@ -284,29 +320,3 @@ public enum ItemSelectionStyle : Equatable } } } - - -public extension Item where Element:Equatable -{ - func elementEqual(to other : Item) -> Bool - { - return self.element == other.element - } -} - - -public extension Array where Element == AnyItem -{ - func elementsEqual(to other : [AnyItem]) -> Bool - { - if self.count != other.count { - return false - } - - let items = zip(self, other) - - return items.allSatisfy { both in - both.0.elementEqual(to: both.1) - } - } -} diff --git a/Listable/Sources/ItemElement.swift b/Listable/Sources/ItemElement.swift index 98838f822..6e0b97e72 100644 --- a/Listable/Sources/ItemElement.swift +++ b/Listable/Sources/ItemElement.swift @@ -6,24 +6,30 @@ // -public protocol ItemElement +public protocol ItemElement where Coordinator.ItemElementType == Self { // // MARK: Identification // - /** - Identifies the element across updates to the list. This value must remain the same, - otherwise the element will be considered a new item, and the old one removed from the list. - - Does not have to be globally unique – the list will make a "best guess" if there are multiple elements - with the same identifier. However, diffing of changes will be more correct with a unique identifier. - - If you're backing your element with some sort of client or server-provided data, consider using its - server or client UUID here, or some other unique identifier from the underlying data model. - */ + /// Identifies the element across updates to the list. This value must remain the same, + /// otherwise the element will be considered a new item, and the old one removed from the list. + /// + /// Does not have to be globally unique – the list will make a "best guess" if there are multiple elements + /// with the same identifier. However, diffing of changes will be more correct with a unique identifier. + /// + /// If you're backing your element with some sort of client or server-provided data, consider using its + /// server or client UUID here, or some other unique identifier from the underlying data model. var identifier : Identifier { get } + // + // MARK: Default Item Properties + // + + /// Default values to assign to various properties on the `Item` which wraps + /// this `ItemElement`, if those values are not passed to the `Item` initializer. + var defaultItemProperties : DefaultItemProperties { get } + // // MARK: Applying To Displayed View // @@ -71,9 +77,8 @@ public protocol ItemElement // MARK: Creating & Providing Swipe Action Views // - /** - - */ + /// The view type to use to render swipe actions (delete, etc) for this item element. + /// A default implementation, which matches `UITableView`, is provided. associatedtype SwipeActionsView: ItemElementSwipeActionsView = DefaultSwipeActionsView // @@ -84,16 +89,32 @@ public protocol ItemElement /// The content view is drawn at the top of the view hierarchy, above the background views. associatedtype ContentView:UIView - /** - Create and return a new content view used to render the element. - - Note - ---- - Do not do configuration in this method that will be changed by your view's theme or appearance – instead - do that work in `apply(to:)`, so the appearance will be updated if the appearance of elements changes. - */ + + /// Create and return a new content view used to render the element. + /// + /// Note + /// ---- + /// Do not do configuration in this method that will be changed by your view's theme or appearance – instead + /// do that work in `apply(to:)`, so the appearance will be updated if the appearance of elements changes. static func createReusableContentView(frame : CGRect) -> ContentView + // + // MARK: Content Coordination + // + + /// The coordinator type to use to manage the live state of the `Item` and `ItemElement`, + /// if you need to update content based on signals such as notifications, view state, appearance state, + /// etc. + associatedtype Coordinator : ItemElementCoordinator = DefaultItemElementCoordinator + + /// The actions passed to the coordinator. + typealias CoordinatorActions = ItemElementCoordinatorActions + /// The info passed to the coordinator. + typealias CoordinatorInfo = ItemElementCoordinatorInfo + + /// Creates a new coordinator with the provided actions and info. + func makeCoordinator(actions : CoordinatorActions, info : CoordinatorInfo) -> Coordinator + // // MARK: Creating & Providing Background Views // @@ -109,14 +130,12 @@ public protocol ItemElement /// associatedtype BackgroundView:UIView = UIView - /** - Create and return a new background view used to render the element's background. - - Note - ---- - Do not do configuration in this method that will be changed by your view's theme or appearance – instead - do that work in `apply(to:)`, so the appearance will be updated if the appearance of elements changes. - */ + /// Create and return a new background view used to render the element's background. + /// + /// Note + /// ---- + /// Do not do configuration in this method that will be changed by your view's theme or appearance – instead + /// do that work in `apply(to:)`, so the appearance will be updated if the appearance of elements changes. static func createReusableBackgroundView(frame : CGRect) -> BackgroundView /// The selected background view used to draw the background of the element when it is selected or highlighted. @@ -130,19 +149,18 @@ public protocol ItemElement /// associatedtype SelectedBackgroundView:UIView = BackgroundView - /** - Create and return a new background view used to render the element's selected background. - - This view is displayed when the element is highlighted or selected. - - If your `BackgroundView` and `SelectedBackgroundView` are the same type, this method - is provided automatically by calling `createReusableBackgroundView`. - - Note - ---- - Do not do configuration in this method that will be changed by your view's theme or appearance – instead - do that work in `apply(to:)`, so the appearance will be updated if the appearance of elements changes. - */ + + /// Create and return a new background view used to render the element's selected background. + /// + /// This view is displayed when the element is highlighted or selected. + /// + /// If your `BackgroundView` and `SelectedBackgroundView` are the same type, this method + /// is provided automatically by calling `createReusableBackgroundView`. + /// + /// Note + /// ---- + /// Do not do configuration in this method that will be changed by your view's theme or appearance – instead + /// do that work in `apply(to:)`, so the appearance will be updated if the appearance of elements changes. static func createReusableSelectedBackgroundView(frame : CGRect) -> SelectedBackgroundView } @@ -162,14 +180,12 @@ public struct ItemElementViews } -/// /// Information about the current state of the element, which is passed to `apply(to:)` /// during configuration and preparation for display. /// /// You can use this information to alter the display of your element, such as changing /// the background color for highlights and selections, providing different corner styles /// for different item positions, etc. -/// public struct ApplyItemElementInfo { /// The state of the `Item` currently displaying the element. Is it highlighted, selected, etc. @@ -183,6 +199,7 @@ public struct ApplyItemElementInfo } +/// Provide a default implementation of `isEquivalent(to:)` if the `ItemElement` is `Equatable`. public extension ItemElement where Self:Equatable { func isEquivalent(to other : Self) -> Bool @@ -192,6 +209,7 @@ public extension ItemElement where Self:Equatable } +/// Implement `wasMoved` in terms of `isEquivalent(to:)` by default. public extension ItemElement { func wasMoved(comparedTo other : Self) -> Bool @@ -201,6 +219,27 @@ public extension ItemElement } +/// Provide a default implementation of `defaultItemProperties` which returns an +/// empty instance that does not provide any defaults. +public extension ItemElement +{ + var defaultItemProperties : DefaultItemProperties { + .init() + } +} + + +/// Provides a default coordinator for items without a specified coordinator. +public extension ItemElement where Coordinator == DefaultItemElementCoordinator +{ + func makeCoordinator(actions : ItemElementCoordinatorActions, info : ItemElementCoordinatorInfo) -> Coordinator + { + DefaultItemElementCoordinator(actions: actions, info: info, view: nil) + } +} + + +/// Provide a UIView when no special background view is specified. public extension ItemElement where BackgroundView == UIView { static func createReusableBackgroundView(frame : CGRect) -> BackgroundView @@ -210,6 +249,7 @@ public extension ItemElement where BackgroundView == UIView } +/// Provide a UIView when no special selected background view is specified. public extension ItemElement where BackgroundView == SelectedBackgroundView { static func createReusableSelectedBackgroundView(frame : CGRect) -> BackgroundView @@ -218,12 +258,11 @@ public extension ItemElement where BackgroundView == SelectedBackgroundView } } -/** - Conform to this protocol to implement a completely custom swipe action view. - If you do so, you're completely responsible for creating and laying out the actions, - as well as updating the layout based on the swipe state. - */ +/// Conform to this protocol to implement a completely custom swipe action view. +/// +/// If you do so, you're completely responsible for creating and laying out the actions, +/// as well as updating the layout based on the swipe state. public protocol ItemElementSwipeActionsView: UIView { var swipeActionsWidth: CGFloat { get } diff --git a/Listable/Sources/ItemElementCoordinator.swift b/Listable/Sources/ItemElementCoordinator.swift new file mode 100644 index 000000000..812561d13 --- /dev/null +++ b/Listable/Sources/ItemElementCoordinator.swift @@ -0,0 +1,201 @@ +// +// ItemElementCoordinator.swift +// Listable +// +// Created by Kyle Van Essen on 5/19/20. +// + + +/// +/// A type which lets you interactively manage the contents of an `Item` or `ItemElement` +/// within a list. +/// +/// Eg, you might create a `ItemElementCoordinator` which listens to a +/// notification, and then updates a field on the `Item` or `ItemElement` in response +/// to this notification. +/// +/// `ItemElementCoordinator` is created when an item is being prepared to be presented +/// on screen for the first time, and lives for as long as the item is present in the list. If you need +/// to pull in any changes to the item due to time passing, you can update the item within the +/// `wasCreated`callback. +/// +/// There are default implementations of all `ItemElementCoordinator` methods. You only +/// need to provide implementations for the methods relevant to you. +/// +/// Example +/// ------- +/// A simple `ItemElementCoordinator` might look like this: +/// +/// ``` +/// final class MyCoordinator : ItemElementCoordinator +/// { +/// typealias ItemElementType = MyElementType +/// +/// let actions: CoordinatorActions +/// let info: CoordinatorInfo +/// var view : View? +/// +/// init(actions: CoordinatorActions, info: CoordinatorInfo) +/// { +/// self.actions = actions +/// self.info = info +/// +/// NotificationCenter.default.addObserver(self, selector: #selector(downloadUpdated(:)), name: .DownloadProgressChanged, object: nil) +/// } +/// +/// @objc func downloadUpdated(notification : Notification) +/// { +/// self.actions.update { +/// $0.element.downloadProgress = notification.userInfo["download_progress"] as! CGFloat +/// } +/// } +/// } +/// ``` +/// +public protocol ItemElementCoordinator : AnyObject +{ + /// The type of `ItemElement` associated with this coordinator. + associatedtype ItemElementType : ItemElement + + // MARK: Actions & Info + + /// The available actions you can perform on the coordinated `Item`. Eg, updating it to a new value. + var actions : ItemElementType.CoordinatorActions { get } + + /// Info about the coordinated `Item`, such as its original and current value. + var info : ItemElementType.CoordinatorInfo { get } + + // MARK: Instance Lifecycle + + /// Invoked on the coordinator when it is first created and configured. + func wasCreated() + + /// Invoked on the coordinator when an external update is pushed onto the owned `Item`. + /// This happens when the developer updates the content of the list, and the item is + /// reported as changed via its `isEquivalent(to:)` method. + func wasUpdated(old : Item, new : Item) + + /// Invoked on the coordinator when its owned item is removed from the list due to + /// the item, or its entire section, being removed from the list. + /// Note invoked during deallocation of a list. + func wasRemoved() + + // MARK: Visibility & View Lifecycle + + /// The view type associated with the item. + typealias View = ItemElementType.ContentView + + /// The view, if any, currently used to display the item. + var view : View? { get set } + + /// Invoked when the list is about to begin displaying the item with the given view. + func willDisplay(with view : View) + + /// Invoked when the list is about to complete displaying the item with the given view. + func didEndDisplay(with view : View) + + // MARK: Selection & Highlight Lifecycle + + /// Invoked when the item is selected, via either user interaction or the `selectionStyle`. + func wasSelected() + + /// Invoked when the item is deselected, via either user interaction or the `selectionStyle`. + func wasDeselected() +} + + +public extension ItemElementCoordinator +{ + // MARK: Instance Lifecycle + + func wasCreated() {} + func wasUpdated(old : Item, new : Item) {} + func wasRemoved() {} + + // MARK: Visibility Lifecycle + + func willDisplay(with view : View) {} + + func didEndDisplay(with view : View) {} + + // MARK: Selection & Highlight Lifecycle + + func wasSelected() {} + + func wasDeselected() {} +} + + +/// The available actions you can perform as a coordinator, which are reported back to the list to manage the item. +public final class ItemElementCoordinatorActions +{ + private let currentProvider : () -> Item + var updateCallback : (Item) -> () + + init(current : @escaping () -> Item, update : @escaping (Item) -> ()) + { + self.currentProvider = current + self.updateCallback = update + } + + /// Updates the item to the provided item. + public func update(_ new : Item) + { + self.updateCallback(new) + } + + /// Allows you to update the item passed into the update closure. + public func update(_ update : (inout Item) -> ()) + { + var updated = self.currentProvider() + + update(&updated) + + self.update(updated) + } +} + + +/// Information about the current and original state of the item. +public final class ItemElementCoordinatorInfo +{ + /// The original state of the item, as passed to the list. + /// This is property is updated when the list is updated, and the + /// `isEquivalent(to:)` reports a change to the item. + public internal(set) var original : Item + + /// The current value of the item, including changes made + /// by the coordinator itself. + public var current : Item { + self.currentProvider() + } + + private let currentProvider : () -> Item + + init(original : Item, current : @escaping () -> Item) + { + self.original = original + + self.currentProvider = current + } +} + + +/// The default `ItemElementCoordinator`, which performs no actions. +public final class DefaultItemElementCoordinator : ItemElementCoordinator +{ + public let actions : Element.CoordinatorActions + public let info : Element.CoordinatorInfo + + public var view : Element.ContentView? + + internal init( + actions: Element.CoordinatorActions, + info: Element.CoordinatorInfo, + view: DefaultItemElementCoordinator.View? + ) { + self.actions = actions + self.info = info + self.view = view + } +} diff --git a/Listable/Sources/Layout/CollectionViewLayout.swift b/Listable/Sources/Layout/CollectionViewLayout.swift index 86be5cbe3..ba19d304a 100644 --- a/Listable/Sources/Layout/CollectionViewLayout.swift +++ b/Listable/Sources/Layout/CollectionViewLayout.swift @@ -118,6 +118,13 @@ final class CollectionViewLayout : UICollectionViewLayout // MARK: Invalidation & Invalidation Contexts // + func setNeedsRelayout() + { + self.neededLayoutType.merge(with: .relayout) + + self.invalidateLayout() + } + private(set) var shouldAskForItemSizesDuringLayoutInvalidation : Bool = false func setShouldAskForItemSizesDuringLayoutInvalidation() diff --git a/Listable/Sources/ListView/ListView.DataSource.swift b/Listable/Sources/ListView/ListView.DataSource.swift index 6f6add8c3..7e91c4926 100644 --- a/Listable/Sources/ListView/ListView.DataSource.swift +++ b/Listable/Sources/ListView/ListView.DataSource.swift @@ -28,7 +28,7 @@ internal extension ListView { let item = self.presentationState.item(at: indexPath) - self.presentationState.registerCell(for: item) + self.presentationState.registerCell(for: item, in: collectionView) return item.dequeueAndPrepareCollectionViewCell(in: collectionView, for: indexPath) } diff --git a/Listable/Sources/ListView/ListView.swift b/Listable/Sources/ListView/ListView.swift index c231fcc7b..5f5d63c9a 100644 --- a/Listable/Sources/ListView/ListView.swift +++ b/Listable/Sources/ListView/ListView.swift @@ -56,9 +56,7 @@ public final class ListView : UIView super.init(frame: frame) // Associate ourselves with our child objects. - - self.storage.presentationState.view = self - + self.dataSource.presentationState = self.storage.presentationState self.delegate.view = self @@ -667,11 +665,11 @@ public final class ListView : UIView } let greaterIndexPath = max(autoScrollIndexPath, indexPath) - visibleSlice = self.storage.allContent.sliceTo(indexPath: greaterIndexPath, plus: Content.Slice.defaultSize) + visibleSlice = self.storage.allContent.sliceTo(indexPath: greaterIndexPath) case .none: - visibleSlice = self.storage.allContent.sliceTo(indexPath: indexPath, plus: Content.Slice.defaultSize) + visibleSlice = self.storage.allContent.sliceTo(indexPath: indexPath) } } @@ -680,7 +678,9 @@ public final class ListView : UIView } let updateBackingData = { - presentationState.update(with: diff, slice: visibleSlice) + let dependencies = ItemStateDependencies(reorderingDelegate: self, coordinatorDelegate: self) + + presentationState.update(with: diff, slice: visibleSlice, dependencies: dependencies, loggable: self) } // Update Refresh Control @@ -691,7 +691,7 @@ public final class ListView : UIView Note: Must be called *OUTSIDE* of CollectionView's `performBatchUpdates:`, otherwise we trigger a bug where updated indexes are calculated incorrectly. */ - presentationState.updateRefreshControl(with: visibleSlice.content.refreshControl) + presentationState.updateRefreshControl(with: visibleSlice.content.refreshControl, in: self.collectionView) // Update Collection View @@ -831,12 +831,30 @@ public final class ListView : UIView ) ) } - +} + + +extension ListView : ItemElementCoordinatorDelegate +{ + func coordinatorUpdated(for : AnyItem) + { + /// Todo... Once https://github.com/kyleve/Listable/pull/129 lands, + /// check if the item is visible, to control if the subsequent update after the + /// invalidation should be animated. + self.applyToVisibleViews() + + self.layoutManager.current.setNeedsRelayout() + } +} + + +extension ListView : ReorderingActionsDelegate +{ // // MARK: Moving Items // - internal func beginInteractiveMovementFor(item : AnyPresentationItemState) -> Bool + func beginInteractiveMovementFor(item : AnyPresentationItemState) -> Bool { guard let indexPath = self.storage.presentationState.indexPath(for: item) else { return false @@ -845,24 +863,25 @@ public final class ListView : UIView return self.collectionView.beginInteractiveMovementForItem(at: indexPath) } - internal func updateInteractiveMovementTargetPosition(with recognizer : UIPanGestureRecognizer) + func updateInteractiveMovementTargetPosition(with recognizer : UIPanGestureRecognizer) { let position = recognizer.location(in: self.collectionView) self.collectionView.updateInteractiveMovementTargetPosition(position) } - internal func endInteractiveMovement() + func endInteractiveMovement() { self.collectionView.endInteractiveMovement() } - private func cancelInteractiveMovement() + func cancelInteractiveMovement() { self.collectionView.cancelInteractiveMovement() } } + extension ListView : SignpostLoggable { var signpostInfo : SignpostLoggingInfo { diff --git a/Listable/Sources/ReorderingActions.swift b/Listable/Sources/ReorderingActions.swift index 5e856d1d4..ad9b74687 100644 --- a/Listable/Sources/ReorderingActions.swift +++ b/Listable/Sources/ReorderingActions.swift @@ -6,12 +6,21 @@ // +protocol ReorderingActionsDelegate : AnyObject +{ + func beginInteractiveMovementFor(item : AnyPresentationItemState) -> Bool + func updateInteractiveMovementTargetPosition(with recognizer : UIPanGestureRecognizer) + func endInteractiveMovement() + func cancelInteractiveMovement() +} + + public final class ReorderingActions { public private(set) var isMoving : Bool internal weak var item : AnyPresentationItemState? - internal weak var listView : ListView? + internal weak var delegate : ReorderingActionsDelegate? init() { @@ -26,8 +35,8 @@ public final class ReorderingActions self.isMoving = true - if let listView = self.listView, let item = self.item { - return listView.beginInteractiveMovementFor(item: item) + if let delegate = self.delegate, let item = self.item { + return delegate.beginInteractiveMovementFor(item: item) } else { return false } @@ -39,7 +48,7 @@ public final class ReorderingActions return } - self.listView?.updateInteractiveMovementTargetPosition(with: recognizer) + self.delegate?.updateInteractiveMovementTargetPosition(with: recognizer) } public func end() @@ -50,6 +59,6 @@ public final class ReorderingActions self.isMoving = false - self.listView?.endInteractiveMovement() + self.delegate?.endInteractiveMovement() } } diff --git a/Listable/Sources/Section.swift b/Listable/Sources/Section.swift index f1383bb46..3148a5f86 100644 --- a/Listable/Sources/Section.swift +++ b/Listable/Sources/Section.swift @@ -211,19 +211,6 @@ public extension SectionInfo } -public extension Section -{ - func elementsEqual(to other : Section) -> Bool - { - if self.items.count != other.items.count { - return false - } - - return self.items.elementsEqual(to: other.items) - } -} - - private struct HashableSectionInfo : SectionInfo { var value : Value diff --git a/Listable/Tests/BindingTests.swift b/Listable/Tests/BindingTests.swift deleted file mode 100644 index bc12c1660..000000000 --- a/Listable/Tests/BindingTests.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// BindingTests.swift -// Listable-Unit-Tests -// -// Created by Kyle Van Essen on 11/27/19. -// - -import XCTest - -class BindingTests: XCTestCase -{ - -} diff --git a/Listable/Tests/Internal/Presentation State/PresentationState.HeaderFooterStateTests.swift b/Listable/Tests/Internal/Presentation State/PresentationState.HeaderFooterStateTests.swift new file mode 100644 index 000000000..c36bfc738 --- /dev/null +++ b/Listable/Tests/Internal/Presentation State/PresentationState.HeaderFooterStateTests.swift @@ -0,0 +1,15 @@ +// +// PresentationState.HeaderFooterStateTests.swift +// Listable-Unit-Tests +// +// Created by Kyle Van Essen on 5/22/20. +// + +import XCTest +@testable import Listable + + +class PresentationState_HeaderFooterStateTests : XCTestCase +{ + +} diff --git a/Listable/Tests/Internal/Presentation State/PresentationState.ItemStateTests.swift b/Listable/Tests/Internal/Presentation State/PresentationState.ItemStateTests.swift new file mode 100644 index 000000000..69f7a40b3 --- /dev/null +++ b/Listable/Tests/Internal/Presentation State/PresentationState.ItemStateTests.swift @@ -0,0 +1,383 @@ +// +// PresentationState.ItemStateTests.swift +// Listable-Unit-Tests +// +// Created by Kyle Van Essen on 5/22/20. +// + +import XCTest +@testable import Listable + + +class PresentationState_ItemStateTests : XCTestCase +{ + func test_init() + { + let coordinatorDelegate = ItemElementCoordinatorDelegateMock() + + let dependencies = ItemStateDependencies( + reorderingDelegate: ReorderingActionsDelegateMock(), + coordinatorDelegate: coordinatorDelegate + ) + + let initial = Item(TestElement(value: "initial")) + + let state = PresentationState.ItemState(with: initial, dependencies: dependencies) + + // Updates within init of the coordinator should not trigger callbacks. + + XCTAssertEqual(coordinatorDelegate.coordinatorUpdated_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.wasCreated_calls.count, 1) + + XCTAssertEqual(state.model.element.updates, [ + "update within coordinator init" + ]) + + // Updates outside of init should trigger coordinator updates. + + state.coordination.coordinator.triggerUpdate(with: "first update") + + XCTAssertEqual(coordinatorDelegate.coordinatorUpdated_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.wasCreated_calls.count, 1) + + XCTAssertEqual(state.model.element.updates, [ + "update within coordinator init", + "first update" + ]) + + state.coordination.coordinator.triggerUpdate(with: "second update") + + XCTAssertEqual(coordinatorDelegate.coordinatorUpdated_calls.count, 2) + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.wasCreated_calls.count, 1) + + XCTAssertEqual(state.model.element.updates, [ + "update within coordinator init", + "first update", + "second update" + ]) + } + + func test_setNew() + { + let dependencies = ItemStateDependencies( + reorderingDelegate: ReorderingActionsDelegateMock(), + coordinatorDelegate: ItemElementCoordinatorDelegateMock() + ) + + let initial = Item(TestElement(value: "initial")) + + var updated = initial + + // Used to identify if the value was updated later on. + updated.element.value = "updated" + + // Calling setNew pulls the isSelected state off of the item. + updated.selectionStyle = .selectable(isSelected: true) + + for reason in PresentationState.ItemUpdateReason.allCases { + switch reason { + case .move: + let state = PresentationState.ItemState(with: initial, dependencies: dependencies) + + XCTAssertEqual(state.model.element.value, "initial") + XCTAssertEqual(state.coordination.info.original.element.value, "initial") + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.storage.state.isSelected, false) + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 0) + + state.setNew(item: updated, reason: .move) + + XCTAssertEqual(state.model.element.value, "updated") + XCTAssertEqual(state.coordination.info.original.element.value, "updated") + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 1) + XCTAssertEqual(state.storage.state.isSelected, true) + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 1) + + case .updateFromList: + let state = PresentationState.ItemState(with: initial, dependencies: dependencies) + + XCTAssertEqual(state.model.element.value, "initial") + XCTAssertEqual(state.coordination.info.original.element.value, "initial") + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.storage.state.isSelected, false) + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 0) + + state.setNew(item: updated, reason: .updateFromList) + + XCTAssertEqual(state.model.element.value, "updated") + XCTAssertEqual(state.coordination.info.original.element.value, "updated") + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 1) + XCTAssertEqual(state.storage.state.isSelected, true) + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 1) + + case .updateFromItemCoordinator: + let state = PresentationState.ItemState(with: initial, dependencies: dependencies) + + XCTAssertEqual(state.model.element.value, "initial") + XCTAssertEqual(state.coordination.info.original.element.value, "initial") + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.storage.state.isSelected, false) + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 0) + + state.setNew(item: updated, reason: .updateFromItemCoordinator) + + XCTAssertEqual(state.model.element.value, "updated") + XCTAssertEqual(state.coordination.info.original.element.value, "initial") + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.storage.state.isSelected, true) + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 1) + + case .noChange: + let state = PresentationState.ItemState(with: initial, dependencies: dependencies) + + XCTAssertEqual(state.model.element.value, "initial") + XCTAssertEqual(state.coordination.info.original.element.value, "initial") + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.storage.state.isSelected, false) + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 0) + + state.setNew(item: updated, reason: .noChange) + + XCTAssertEqual(state.model.element.value, "updated") + XCTAssertEqual(state.coordination.info.original.element.value, "initial") + XCTAssertEqual(state.coordination.coordinator.wasUpdated_calls.count, 0) + XCTAssertEqual(state.storage.state.isSelected, true) + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 1) + } + } + } + + func test_stateWasUpdated() + { + let dependencies = ItemStateDependencies( + reorderingDelegate: ReorderingActionsDelegateMock(), + coordinatorDelegate: ItemElementCoordinatorDelegateMock() + ) + + let item = Item(TestElement(value: "initial")) + + let state = PresentationState.ItemState(with: item, dependencies: dependencies) + + // Was Selected / Deselected + + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.wasDeselected_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.willDisplay_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.didEndDisplay_calls.count, 0) + + state.stateWasUpdated( + old: .init( + isSelected: false, + visibleCell: nil + ), new: .init( + isSelected: true, + visibleCell: nil + ) + ) + + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.wasDeselected_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.willDisplay_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.didEndDisplay_calls.count, 0) + + state.stateWasUpdated( + old: .init( + isSelected: true, + visibleCell: nil + ), new: .init( + isSelected: false, + visibleCell: nil + ) + ) + + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.wasDeselected_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.willDisplay_calls.count, 0) + XCTAssertEqual(state.coordination.coordinator.didEndDisplay_calls.count, 0) + + // Visible Cells + + state.stateWasUpdated( + old: .init( + isSelected: false, + visibleCell: nil + ), new: .init( + isSelected: false, + visibleCell: ItemElementCell() + ) + ) + + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.wasDeselected_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.willDisplay_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.didEndDisplay_calls.count, 0) + + state.stateWasUpdated( + old: .init( + isSelected: false, + visibleCell: ItemElementCell() + ), new: .init( + isSelected: false, + visibleCell: nil + ) + ) + + XCTAssertEqual(state.coordination.coordinator.wasSelected_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.wasDeselected_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.willDisplay_calls.count, 1) + XCTAssertEqual(state.coordination.coordinator.didEndDisplay_calls.count, 1) + } +} + + +class PresentationState_ItemState_StorageTests : XCTestCase +{ + func test_init() + { + let item = Item(TestElement(value: "initial"), selectionStyle: .selectable(isSelected: true)) + + let storage = PresentationState.ItemState.Storage(item) + + XCTAssertEqual(storage.state.isSelected, true) + XCTAssertEqual(storage.state.visibleCell, nil) + } +} + + +fileprivate struct TestElement : ItemElement, Equatable +{ + typealias ContentView = UIView + + var value : String + var updates : [String] = [] + + var identifier: Identifier = .init("") + + func apply(to views: ItemElementViews, for reason: ApplyReason, with info: ApplyItemElementInfo) {} + + static func createReusableContentView(frame: CGRect) -> UIView { + UIView(frame: frame) + } + + func makeCoordinator(actions: CoordinatorActions, info: CoordinatorInfo) -> Coordinator + { + Coordinator(actions: actions, info: info) + } + + final class Coordinator : ItemElementCoordinator + { + init(actions : TestElement.CoordinatorActions, info : TestElement.CoordinatorInfo) + { + self.actions = actions + self.info = info + + self.triggerUpdate(with: "update within coordinator init") + } + + func triggerUpdate(with newContent : String) + { + self.actions.update { + $0.element.updates.append(newContent) + } + } + + // MARK: ItemElementCoordinator + + typealias ItemElementType = TestElement + + var actions: CoordinatorActions + var info: CoordinatorInfo + + // MARK: ItemElementCoordinator - Instance Lifecycle + + var wasCreated_calls: [Void] = [Void]() + + func wasCreated() + { + self.wasCreated_calls.append(()) + } + + var wasUpdated_calls = [(old : Item, new : Item)]() + + func wasUpdated(old : Item, new : Item) + { + self.wasUpdated_calls.append((old, new)) + } + + var wasRemoved_calls: [Void] = [Void]() + + func wasRemoved() + { + self.wasRemoved_calls.append(()) + } + + // MARK: ItemElementCoordinator - Visibility & View Lifecycle + + typealias View = ItemElementType.ContentView + + var view_didSet_calls = [View?]() + + var view : View? { + didSet { + self.view_didSet_calls.append(self.view) + } + } + + var willDisplay_calls = [View]() + + func willDisplay(with view : View) + { + self.willDisplay_calls.append(view) + } + + var didEndDisplay_calls = [View]() + + func didEndDisplay(with view : View) + { + self.didEndDisplay_calls.append(view) + } + + // MARK: ItemElementCoordinator - Selection & Highlight Lifecycle + + var wasSelected_calls: [Void] = [Void]() + + + func wasSelected() + { + self.wasSelected_calls.append(()) + } + + var wasDeselected_calls: [Void] = [Void]() + + func wasDeselected() + { + self.wasDeselected_calls.append(()) + } + } +} + + +fileprivate class ItemElementCoordinatorDelegateMock : ItemElementCoordinatorDelegate +{ + var coordinatorUpdated_calls = [AnyItem]() + + func coordinatorUpdated(for item: AnyItem) + { + self.coordinatorUpdated_calls.append(item) + } +} + + +fileprivate class ReorderingActionsDelegateMock : ReorderingActionsDelegate +{ + func beginInteractiveMovementFor(item: AnyPresentationItemState) -> Bool { true } + + func updateInteractiveMovementTargetPosition(with recognizer: UIPanGestureRecognizer) {} + + func endInteractiveMovement() {} + + func cancelInteractiveMovement() {} +} diff --git a/Listable/Tests/Internal/Presentation State/PresentationState.SectionStateTests.swift b/Listable/Tests/Internal/Presentation State/PresentationState.SectionStateTests.swift new file mode 100644 index 000000000..b4b0f3d37 --- /dev/null +++ b/Listable/Tests/Internal/Presentation State/PresentationState.SectionStateTests.swift @@ -0,0 +1,15 @@ +// +// PresentationState.SectionStateTests.swift +// Listable-Unit-Tests +// +// Created by Kyle Van Essen on 5/22/20. +// + +import XCTest +@testable import Listable + + +class PresentationState_SectionStateTests : XCTestCase +{ + +} diff --git a/Listable/Tests/Internal/PresentationStateTests.swift b/Listable/Tests/Internal/Presentation State/PresentationStateTests.swift similarity index 100% rename from Listable/Tests/Internal/PresentationStateTests.swift rename to Listable/Tests/Internal/Presentation State/PresentationStateTests.swift diff --git a/Listable/Tests/ItemElementCoordinatorTests.swift b/Listable/Tests/ItemElementCoordinatorTests.swift new file mode 100644 index 000000000..8671eef10 --- /dev/null +++ b/Listable/Tests/ItemElementCoordinatorTests.swift @@ -0,0 +1,81 @@ +// +// ItemElementCoordinatorTests.swift +// Listable-Unit-Tests +// +// Created by Kyle Van Essen on 5/22/20. +// + +import XCTest + +@testable import Listable + + +class ItemElementCoordinatorActionsTests : XCTestCase +{ + func test_update() + { + var item = Item(TestElement(value: "first")) + + var callbackCount = 0 + + let actions = ItemElementCoordinatorActions(current: { item }, update: { new in + item = new + callbackCount += 1 + }) + + self.testcase("Setter based update") { + + var updated = item + updated.element.value = "update1" + actions.update(updated) + + XCTAssertEqual(item.element.value, "update1") + XCTAssertEqual(callbackCount, 1) + } + + self.testcase("Closure based update") { + + actions.update { + $0.element.value = "update2" + } + + XCTAssertEqual(item.element.value, "update2") + XCTAssertEqual(callbackCount, 2) + } + } +} + + +class ItemElementCoordinatorInfoTests : XCTestCase +{ + func test() + { + let original = Item(TestElement(value: "original")) + var current = original + + let info = ItemElementCoordinatorInfo(original: original, current: { current }) + + current.element.value = "current" + + XCTAssertEqual(info.original.element.value, "original") + XCTAssertEqual(info.current.element.value, "current") + } +} + + +fileprivate struct TestElement : ItemElement, Equatable +{ + var value : String + + typealias ContentView = UIView + + var identifier: Identifier { + .init(self.value) + } + + func apply(to views: ItemElementViews, for reason: ApplyReason, with info: ApplyItemElementInfo) {} + + static func createReusableContentView(frame: CGRect) -> UIView { + UIView(frame: frame) + } +} From 42602d11f3fe5d90fa3120ab21c8e74005c6b777 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 22 May 2020 18:30:32 -0700 Subject: [PATCH 2/2] Remove duplicate file --- BlueprintLists/Sources/BlueprintListsModule.swift | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 BlueprintLists/Sources/BlueprintListsModule.swift diff --git a/BlueprintLists/Sources/BlueprintListsModule.swift b/BlueprintLists/Sources/BlueprintListsModule.swift deleted file mode 100644 index 7544a6253..000000000 --- a/BlueprintLists/Sources/BlueprintListsModule.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// BlueprintListsModule.swift -// BlueprintLists -// -// Created by Kyle Bashour on 5/20/20. -// - -/// Since many of BlueprintLists types use Listable types, we export the public API of Listable -/// when importing BlueprintLists. -@_exported import Listable