Skip to content

Commit

Permalink
Layout Equivalence.
Browse files Browse the repository at this point in the history
  • Loading branch information
andersio committed Feb 3, 2019
1 parent 0cd6b30 commit 4e5379e
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 8 deletions.
4 changes: 4 additions & 0 deletions Bento.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -178,6 +179,7 @@
61B64BB02086561B0092082C /* IntroRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroRenderer.swift; sourceTree = "<group>"; };
61B64BB320873DA10092082C /* CollectionViewSectionDiff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewSectionDiff.swift; sourceTree = "<group>"; };
61B64BB5208745730092082C /* CollectionViewContainerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewContainerCell.swift; sourceTree = "<group>"; };
65020C332205E0B500DC8F42 /* LayoutCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LayoutCompatibility.swift; sourceTree = "<group>"; };
65A69EDE218B8891005D90AC /* UICollectionViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UICollectionViewExtensions.swift; sourceTree = "<group>"; };
65A69EE0218B8AB6005D90AC /* CustomCollectionViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCollectionViewAdapter.swift; sourceTree = "<group>"; };
65BA922D20AF388F004AEF18 /* UITableViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITableViewExtensions.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -367,6 +369,7 @@
65E3ECA1211317EA00869DF3 /* FocusCoordinator.swift */,
65E3ECA521133C9400869DF3 /* UIKit+CollectionViewFocus.swift */,
582D9986217F87B100C67B0D /* ComponentLifecycleAware.swift */,
65020C332205E0B500DC8F42 /* LayoutCompatibility.swift */,
);
path = Renderable;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
56 changes: 50 additions & 6 deletions Bento/Renderable/AnyRenderable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public struct AnyRenderable: Renderable {
return base.viewType
}

private let base: AnyRenderableBoxBase
fileprivate let base: AnyRenderableBoxBase

init<Base: Renderable>(_ base: Base) where Base.View: UIView {
self.base = AnyRenderableBox(base)
Expand Down Expand Up @@ -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<Base: Renderable> = AnyRenderableBox<Base> where Base.View: UIView
typealias LayoutContributingBehavior<Base: Renderable> = LayoutContributingBehaviorBox<Base> where Base.View: UIView

class LayoutContributingBehaviorBox<Base: Renderable>: AnyRenderableBox<Base> 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<Base: Renderable>: AnyRenderableBoxBase where Base.View: UIView {
Expand All @@ -73,6 +87,10 @@ class AnyRenderableBox<Base: Renderable>: 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) {
Expand All @@ -94,19 +112,45 @@ class AnyRenderableBox<Base: Renderable>: AnyRenderableBoxBase where Base.View:
}
return base as? T
}

override func layoutEquivalence(with other: AnyRenderableBoxBase) -> LayoutEquivalence {
if let other = other as? AnyRenderableBox<Base> {
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<T>(to type: T.Type) -> T? { fatalError() }
func render(in view: UIView) { notImplemented() }
func generate() -> UIView { notImplemented() }
func cast<T>(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.")
}
2 changes: 1 addition & 1 deletion Bento/Renderable/ComponentLifecycleAware.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ public protocol ViewLifecycleAware {
func didEndDisplayingView()
}

final class LifecycleComponent<Base: Renderable>: AnyRenderableBox<Base>, ComponentLifecycleAware where Base.View: UIView {
final class LifecycleComponent<Base: Renderable>: NoLayoutBehavior<Base>, ComponentLifecycleAware where Base.View: UIView {
private let source: AnyRenderableBox<Base>
private let _willDisplayItem: (() -> Void)?
private let _didEndDisplayingItem: (() -> Void)?
Expand Down
2 changes: 1 addition & 1 deletion Bento/Renderable/Deletable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ protocol Deletable {
func delete()
}

final class DeletableComponent<Base: Renderable>: AnyRenderableBox<Base>, Deletable where Base.View: UIView {
final class DeletableComponent<Base: Renderable>: NoLayoutBehavior<Base>, Deletable where Base.View: UIView {
let deleteActionText: String
let backgroundColor: UIColor?
private let source: AnyRenderableBox<Base>
Expand Down
12 changes: 12 additions & 0 deletions Bento/Renderable/LayoutCompatibility.swift
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions Bento/Renderable/Renderable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
71 changes: 71 additions & 0 deletions BentoTests/AnyRenderableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {}
}

0 comments on commit 4e5379e

Please sign in to comment.