Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Layout Equivalence. #101

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
andersio marked this conversation as resolved.
Show resolved Hide resolved
}
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) {}
}