From 4e5379eedea26b52cd4cb4cea7bfd5fc904a2db2 Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Thu, 31 Jan 2019 14:36:00 +0000 Subject: [PATCH 1/2] Layout Equivalence. --- Bento.xcodeproj/project.pbxproj | 4 ++ Bento/Renderable/AnyRenderable.swift | 56 +++++++++++++-- .../Renderable/ComponentLifecycleAware.swift | 2 +- Bento/Renderable/Deletable.swift | 2 +- Bento/Renderable/LayoutCompatibility.swift | 12 ++++ Bento/Renderable/Renderable.swift | 24 +++++++ BentoTests/AnyRenderableTests.swift | 71 +++++++++++++++++++ 7 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 Bento/Renderable/LayoutCompatibility.swift diff --git a/Bento.xcodeproj/project.pbxproj b/Bento.xcodeproj/project.pbxproj index 7290089..f90f76d 100644 --- a/Bento.xcodeproj/project.pbxproj +++ b/Bento.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ 61B64BB12086561B0092082C /* IntroRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B64BB02086561B0092082C /* IntroRenderer.swift */; }; 61B64BB420873DA10092082C /* CollectionViewSectionDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B64BB320873DA10092082C /* CollectionViewSectionDiff.swift */; }; 61B64BB6208745730092082C /* CollectionViewContainerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61B64BB5208745730092082C /* CollectionViewContainerCell.swift */; }; + 65020C342205E0B500DC8F42 /* LayoutCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65020C332205E0B500DC8F42 /* LayoutCompatibility.swift */; }; 65496FB8211C323F00511D8A /* TableViewContainerCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587F0717201B355800ACD219 /* TableViewContainerCell.swift */; }; 65496FB9211C323F00511D8A /* TableViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9509BC2762C8B4277B973D8 /* TableViewAdapter.swift */; }; 65A69EDF218B8892005D90AC /* UICollectionViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65A69EDE218B8891005D90AC /* UICollectionViewExtensions.swift */; }; @@ -178,6 +179,7 @@ 61B64BB02086561B0092082C /* IntroRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroRenderer.swift; sourceTree = ""; }; 61B64BB320873DA10092082C /* CollectionViewSectionDiff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewSectionDiff.swift; sourceTree = ""; }; 61B64BB5208745730092082C /* CollectionViewContainerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewContainerCell.swift; sourceTree = ""; }; + 65020C332205E0B500DC8F42 /* LayoutCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCompatibility.swift; sourceTree = ""; }; 65A69EDE218B8891005D90AC /* UICollectionViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICollectionViewExtensions.swift; sourceTree = ""; }; 65A69EE0218B8AB6005D90AC /* CustomCollectionViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCollectionViewAdapter.swift; sourceTree = ""; }; 65BA922D20AF388F004AEF18 /* UITableViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewExtensions.swift; sourceTree = ""; }; @@ -367,6 +369,7 @@ 65E3ECA1211317EA00869DF3 /* FocusCoordinator.swift */, 65E3ECA521133C9400869DF3 /* UIKit+CollectionViewFocus.swift */, 582D9986217F87B100C67B0D /* ComponentLifecycleAware.swift */, + 65020C332205E0B500DC8F42 /* LayoutCompatibility.swift */, ); path = Renderable; sourceTree = ""; @@ -646,6 +649,7 @@ 58D0F12D207F573B00A24E96 /* TableViewAnimation.swift in Sources */, 65BA922E20AF388F004AEF18 /* UITableViewExtensions.swift in Sources */, 65A69EDF218B8892005D90AC /* UICollectionViewExtensions.swift in Sources */, + 65020C342205E0B500DC8F42 /* LayoutCompatibility.swift in Sources */, 58BA7584201633CC0050D5F1 /* Renderable.swift in Sources */, 5857BECA2056F02C0085EB9C /* If.swift in Sources */, A9509FB12CAD89179FAA03B0 /* Box.swift in Sources */, diff --git a/Bento/Renderable/AnyRenderable.swift b/Bento/Renderable/AnyRenderable.swift index f6188b0..0ba7822 100644 --- a/Bento/Renderable/AnyRenderable.swift +++ b/Bento/Renderable/AnyRenderable.swift @@ -9,7 +9,7 @@ public struct AnyRenderable: Renderable { return base.viewType } - private let base: AnyRenderableBoxBase + fileprivate let base: AnyRenderableBoxBase init(_ base: Base) where Base.View: UIView { self.base = AnyRenderableBox(base) @@ -62,6 +62,20 @@ public struct AnyRenderable: Renderable { return view } + + public static func layoutEquivalence(_ lhs: AnyRenderable, _ rhs: AnyRenderable) -> LayoutEquivalence { + return lhs.base.closestLayoutContributor + .layoutEquivalence(with: rhs.base.closestLayoutContributor) + } +} + +typealias NoLayoutBehavior = AnyRenderableBox where Base.View: UIView +typealias LayoutContributingBehavior = LayoutContributingBehaviorBox where Base.View: UIView + +class LayoutContributingBehaviorBox: AnyRenderableBox where Base.View: UIView { + override var viewType: Any.Type { notImplemented() } + override var closestLayoutContributor: AnyRenderableBoxBase { return self } + override func layoutEquivalence(with other: AnyRenderableBoxBase) -> LayoutEquivalence { return .unknown } } class AnyRenderableBox: AnyRenderableBoxBase where Base.View: UIView { @@ -73,6 +87,10 @@ class AnyRenderableBox: AnyRenderableBoxBase where Base.View: return Base.View.self } + override var closestLayoutContributor: AnyRenderableBoxBase { + return (base as? AnyRenderable)?.base.closestLayoutContributor ?? self + } + let base: Base init(_ base: Base) { @@ -94,19 +112,45 @@ class AnyRenderableBox: AnyRenderableBoxBase where Base.View: } return base as? T } + + override func layoutEquivalence(with other: AnyRenderableBoxBase) -> LayoutEquivalence { + if let other = other as? AnyRenderableBox { + return Base.layoutEquivalence(base, other.base) + } + + // NOTE: Different component types mean always different layouts. + return .different + } } class AnyRenderableBoxBase { - var reuseIdentifier: String { fatalError() } + var reuseIdentifier: String { notImplemented() } + var viewType: Any.Type { notImplemented() } - var viewType: Any.Type { fatalError() } + /// The closest layout contributor from `self`, including `self`. When there are behaviors attached, this is usually + /// the innermost, original component. But it could also be a layout contributing behavior. + /// + /// - warning: If you implement a behavior that would contribute to the layout, you must override + /// `closestLayoutContributor` to specify `self`. + var closestLayoutContributor: AnyRenderableBoxBase { notImplemented() } init() {} func asAnyRenderable() -> AnyRenderable { return AnyRenderable(self) } - func render(in view: UIView) { fatalError() } - func generate() -> UIView { fatalError() } - func cast(to type: T.Type) -> T? { fatalError() } + func render(in view: UIView) { notImplemented() } + func generate() -> UIView { notImplemented() } + func cast(to type: T.Type) -> T? { notImplemented() } + + /// Evaluate whether `self` should have the same layout as `other`. + /// + /// - warning: If you implement a behavior that would contribute to the layout, you must override + /// `layoutEquivalence(with:)`. In the path leading to `.same`, you must consider the layout equivalence + /// of the base components of both `self` and `other`. + func layoutEquivalence(with other: AnyRenderableBoxBase) -> LayoutEquivalence { notImplemented() } +} + +private func notImplemented(function: StaticString = #function) -> Never { + fatalError("`\(function)` should have been overriden.") } diff --git a/Bento/Renderable/ComponentLifecycleAware.swift b/Bento/Renderable/ComponentLifecycleAware.swift index f82a702..eb699a6 100644 --- a/Bento/Renderable/ComponentLifecycleAware.swift +++ b/Bento/Renderable/ComponentLifecycleAware.swift @@ -8,7 +8,7 @@ public protocol ViewLifecycleAware { func didEndDisplayingView() } -final class LifecycleComponent: AnyRenderableBox, ComponentLifecycleAware where Base.View: UIView { +final class LifecycleComponent: NoLayoutBehavior, ComponentLifecycleAware where Base.View: UIView { private let source: AnyRenderableBox private let _willDisplayItem: (() -> Void)? private let _didEndDisplayingItem: (() -> Void)? diff --git a/Bento/Renderable/Deletable.swift b/Bento/Renderable/Deletable.swift index 579136f..20f6644 100644 --- a/Bento/Renderable/Deletable.swift +++ b/Bento/Renderable/Deletable.swift @@ -7,7 +7,7 @@ protocol Deletable { func delete() } -final class DeletableComponent: AnyRenderableBox, Deletable where Base.View: UIView { +final class DeletableComponent: NoLayoutBehavior, Deletable where Base.View: UIView { let deleteActionText: String let backgroundColor: UIColor? private let source: AnyRenderableBox diff --git a/Bento/Renderable/LayoutCompatibility.swift b/Bento/Renderable/LayoutCompatibility.swift new file mode 100644 index 0000000..94ed55b --- /dev/null +++ b/Bento/Renderable/LayoutCompatibility.swift @@ -0,0 +1,12 @@ +/// Specify whether the layouts of two instances of the same component are equivalent to each other. +public enum LayoutEquivalence { + /// The layouts are the same, and Bento may reuse any cached layout parameter. + case same + + /// The layouts should be different from each other, and Bento must invalidate any cached layout parameter. + case different + + /// The component does not have a definition of layout equivalence. Bento must treat all instances as always + /// different from each other. + case unknown +} diff --git a/Bento/Renderable/Renderable.swift b/Bento/Renderable/Renderable.swift index f3c5469..8b1f927 100644 --- a/Bento/Renderable/Renderable.swift +++ b/Bento/Renderable/Renderable.swift @@ -6,10 +6,34 @@ public protocol Renderable { var reuseIdentifier: String { get } func generate() -> View + + /// Render `self` to the given view. + /// + /// - parameters: + /// - view: The view to render `self` in. func render(in view: View) + + /// Evaluate whether two instances of `Self` result in compatible layouts. + /// + /// In absence of a layout compatibility definition, Bento would be conservative regarding caching of information at + /// the given ID path. + /// + /// - important: Layout evaluation is performed only on the root component, in the case of infinitely nested + /// `AnyRenderable` wrapping with or without added behaviors. + /// + /// - parameters: + /// - lhs: The first component to evaluate. + /// - rhs: The second component to evaluate. + /// + /// - returns: A `LayoutEquivalence` value specifying the layout compatibility. + static func layoutEquivalence(_ lhs: Self, _ rhs: Self) -> LayoutEquivalence } public extension Renderable { + static func layoutEquivalence(_ lhs: Self, _ rhs: Self) -> LayoutEquivalence { + return .unknown + } + var reuseIdentifier: String { return String(reflecting: View.self) } diff --git a/BentoTests/AnyRenderableTests.swift b/BentoTests/AnyRenderableTests.swift index b50969a..a3f0773 100644 --- a/BentoTests/AnyRenderableTests.swift +++ b/BentoTests/AnyRenderableTests.swift @@ -22,6 +22,56 @@ class AnyRenderableTests: XCTestCase { renderable.render(in: testView) expect(testView.hasInvoked) == true } + + func test_layoutEquivalenceEvaluation() { + verifyLayoutEquivalenceEvaluation(wrapper: { $0 }) + } + + func test_layoutEquivalenceEvaluation_nested() { + verifyLayoutEquivalenceEvaluation(wrapper: { AnyRenderable($0) }) + } + + func test_layoutEquivalenceEvaluation_nestedTwice() { + verifyLayoutEquivalenceEvaluation(wrapper: { AnyRenderable(AnyRenderable($0)) }) + } + + func test_layoutEquivalenceEvaluation_nestedWithBehaviorInjection() { + verifyLayoutEquivalenceEvaluation(wrapper: { $0.on(willDisplayItem: nil, didEndDisplayingItem: nil) }) + } + + private func verifyLayoutEquivalenceEvaluation( + wrapper: (AnyRenderable) -> AnyRenderable + ) { + let compatibility1 = AnyRenderable.layoutEquivalence( + wrapper(AnyRenderable(TestContentRenderable(content: "LHS"))), + wrapper(AnyRenderable(TestContentRenderable(content: "RHS"))) + ) + expect(compatibility1) == .different + + let compatibility2 = AnyRenderable.layoutEquivalence( + wrapper(AnyRenderable(TestContentRenderable(content: "LHS"))), + wrapper(AnyRenderable(TestContentRenderable(content: "LHS"))) + ) + expect(compatibility2) == .same + + let compatibility3 = AnyRenderable.layoutEquivalence( + wrapper(AnyRenderable(TestContentRenderable(content: "LHS"))), + wrapper(AnyRenderable(TestEmptyRenderable())) + ) + expect(compatibility3) == .different + + let compatibility4 = AnyRenderable.layoutEquivalence( + wrapper(AnyRenderable(TestEmptyRenderable())), + wrapper(AnyRenderable(TestContentRenderable(content: "RHS"))) + ) + expect(compatibility4) == .different + + let compatibility5 = AnyRenderable.layoutEquivalence( + wrapper(AnyRenderable(TestEmptyRenderable())), + wrapper(AnyRenderable(TestEmptyRenderable())) + ) + expect(compatibility5) == .unknown + } } private class TestView: UIView { @@ -49,3 +99,24 @@ private final class TestRenderable: Renderable { return generateAction() } } + +struct TestContentRenderable: Renderable { + let content: String + + init(content: String) { + self.content = content + } + + func render(in view: UIView) {} + + static func layoutEquivalence(_ lhs: TestContentRenderable, _ rhs: TestContentRenderable) -> LayoutEquivalence { + return lhs.content == rhs.content ? .same : .different + } +} + + +struct TestEmptyRenderable: Renderable { + init() {} + + func render(in view: UIView) {} +} From b703e0c432bf5ab96bea7d206ab964c73c882fa1 Mon Sep 17 00:00:00 2001 From: Anders Ha Date: Tue, 5 Feb 2019 13:51:36 +0800 Subject: [PATCH 2/2] `unknown` as an alias of `different`. --- Bento/Renderable/LayoutCompatibility.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bento/Renderable/LayoutCompatibility.swift b/Bento/Renderable/LayoutCompatibility.swift index 94ed55b..c6208bc 100644 --- a/Bento/Renderable/LayoutCompatibility.swift +++ b/Bento/Renderable/LayoutCompatibility.swift @@ -8,5 +8,5 @@ public enum LayoutEquivalence { /// The component does not have a definition of layout equivalence. Bento must treat all instances as always /// different from each other. - case unknown + public static var unknown: LayoutEquivalence { return .different } }