Skip to content
This repository has been archived by the owner on Feb 17, 2021. It is now read-only.

Commit

Permalink
Embedded layout support (#247)
Browse files Browse the repository at this point in the history
* Allow embedding layouts in a view hierarchy created from another layout
  • Loading branch information
crleona authored and staguer committed Jan 24, 2019
1 parent 187c7ae commit f72c4df
Show file tree
Hide file tree
Showing 6 changed files with 208 additions and 18 deletions.
12 changes: 10 additions & 2 deletions LayoutKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@
7EECD05B2053916C003DC4B1 /* LOKLabelLayoutBuilder.h in Headers */ = {isa = PBXBuildFile; fileRef = 7E7370EC2051E08F007C19FF /* LOKLabelLayoutBuilder.h */; settings = {ATTRIBUTES = (Public, ); }; };
7EECD05C2053916C003DC4B1 /* LOKTextViewLayoutBuilder.h in Headers */ = {isa = PBXBuildFile; fileRef = 7E73710420520F5F007C19FF /* LOKTextViewLayoutBuilder.h */; settings = {ATTRIBUTES = (Public, ); }; };
7EECD0632053942F003DC4B1 /* LayoutKitObjC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EECD0612053916C003DC4B1 /* LayoutKitObjC.framework */; };
A189721221B8BB8400DDA616 /* EmbeddedLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A189721021B8BB3B00DDA616 /* EmbeddedLayoutTests.swift */; };
A189721321B8BB8500DDA616 /* EmbeddedLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A189721021B8BB3B00DDA616 /* EmbeddedLayoutTests.swift */; };
A189721521B8CDA000DDA616 /* EmbeddedLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A189721021B8BB3B00DDA616 /* EmbeddedLayoutTests.swift */; };
AD2C36441EA5AFB500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2C36421EA5AF9500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift */; };
ADE5FCC11EA5B5F3006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADE5FCBF1EA5B5C8006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift */; };
CDD4F71020EC727800DB358C /* CollectionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B193BB61D887BCF00FCA22D /* CollectionExtension.swift */; };
Expand Down Expand Up @@ -528,6 +531,7 @@
7EEA2ACC201D1FE90077A088 /* Launch Screen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = "Launch Screen.storyboard"; sourceTree = "<group>"; };
7EECD0612053916C003DC4B1 /* LayoutKitObjC.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = LayoutKitObjC.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7EECD0622053916C003DC4B1 /* LayoutKit-iOS copy-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "LayoutKit-iOS copy-Info.plist"; path = "/Users/staguer/ws/lk0/LayoutKit-iOS copy-Info.plist"; sourceTree = "<absolute>"; };
A189721021B8BB3B00DDA616 /* EmbeddedLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmbeddedLayoutTests.swift; sourceTree = "<group>"; };
AD2C36421EA5AF9500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift; sourceTree = "<group>"; };
ADE5FCBF1EA5B5C8006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReloadableViewLayoutAdapterTableViewOverrideTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -726,16 +730,17 @@
0B193BB61D887BCF00FCA22D /* CollectionExtension.swift */,
0BCB76551D8725310065E02A /* CollectionViewTests.swift */,
0BCB76561D8725310065E02A /* DensityAssertions.swift */,
A189721021B8BB3B00DDA616 /* EmbeddedLayoutTests.swift */,
0B193BB71D887BCF00FCA22D /* IndexSetExtension.swift */,
0BCB756F1D8720110065E02A /* Info.plist */,
0BCB76571D8725310065E02A /* InsetLayoutTests.swift */,
0BCB76581D8725310065E02A /* LabelLayoutTests.swift */,
0BCB76591D8725310065E02A /* LayoutArrangementTests.swift */,
75D94A3A1EA045F100A5FD01 /* OverlayLayoutTests.swift */,
0BCB765C1D8725310065E02A /* ReloadableViewLayoutAdapterCollectionViewTests.swift */,
AD2C36421EA5AF9500550A03 /* ReloadableViewLayoutAdapterCollectionViewOverrideTests.swift */,
0BCB765D1D8725310065E02A /* ReloadableViewLayoutAdapterTableViewTests.swift */,
0BCB765C1D8725310065E02A /* ReloadableViewLayoutAdapterCollectionViewTests.swift */,
ADE5FCBF1EA5B5C8006A3DC2 /* ReloadableViewLayoutAdapterTableViewOverrideTests.swift */,
0BCB765D1D8725310065E02A /* ReloadableViewLayoutAdapterTableViewTests.swift */,
0BCB765E1D8725310065E02A /* ReloadableViewLayoutAdapterTestCase.swift */,
0BDDF95A1E25ACCE008B0A6F /* ReloadableViewTests.swift */,
0BCB765F1D8725310065E02A /* SizeLayoutTests.swift */,
Expand Down Expand Up @@ -1387,6 +1392,7 @@
0B2D092C1D872F75007E487C /* ReloadableViewLayoutAdapterCollectionViewTests.swift in Sources */,
CDD4F71320EC728200DB358C /* IndexSetExtension.swift in Sources */,
0B2D092E1D872F75007E487C /* ReloadableViewLayoutAdapterTestCase.swift in Sources */,
A189721321B8BB8500DDA616 /* EmbeddedLayoutTests.swift in Sources */,
0B2D092D1D872F75007E487C /* ReloadableViewLayoutAdapterTableViewTests.swift in Sources */,
0B2D09321D872F75007E487C /* StackLayoutSpacingTests.swift in Sources */,
75D94A3B1EA045F100A5FD01 /* OverlayLayoutTests.swift in Sources */,
Expand Down Expand Up @@ -1474,6 +1480,7 @@
0B2D093C1D872F75007E487C /* DensityAssertions.swift in Sources */,
CDD4F71420EC728300DB358C /* IndexSetExtension.swift in Sources */,
0BB380DC1DB73EFF00E2614F /* TextExtension.swift in Sources */,
A189721221B8BB8400DDA616 /* EmbeddedLayoutTests.swift in Sources */,
0B8C078C1DC3E88A001CD5EE /* ButtonLayoutTests.swift in Sources */,
0BDDF95C1E25ACCE008B0A6F /* ReloadableViewTests.swift in Sources */,
0B2D093D1D872F75007E487C /* InsetLayoutTests.swift in Sources */,
Expand Down Expand Up @@ -1527,6 +1534,7 @@
0B2D09531D872F76007E487C /* InsetLayoutTests.swift in Sources */,
CDD4F71220EC727900DB358C /* CollectionExtension.swift in Sources */,
0BA02E481D874BBB00F1E8D3 /* LayoutArrangementTests.swift in Sources */,
A189721521B8CDA000DDA616 /* EmbeddedLayoutTests.swift in Sources */,
75D94A3D1EA045F100A5FD01 /* OverlayLayoutTests.swift in Sources */,
0B2D095B1D872F76007E487C /* SizeLayoutTests.swift in Sources */,
0B2D09621D872F76007E487C /* TestStack.swift in Sources */,
Expand Down
92 changes: 92 additions & 0 deletions LayoutKitTests/EmbeddedLayoutTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright 2016 LinkedIn Corp.
// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
// You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.

import XCTest
@testable import LayoutKit

class EmbeddedLayoutTests: XCTestCase {

private let rootView = View()
private let singleViewArrangement = SizeLayout(minSize: .zero, config: { _ in }).arrangement()

override func setUp() {
super.setUp()

rootView.subviews.forEach { $0.removeFromSuperview() }
}

func testKeepsSubviewsForEmbeddedLayoutWithReuseId() {
var parentView: View?
let parentLayout = SizeLayout(minSize: .zero, viewReuseId: "test", config: { view in
parentView = view
})

// Create Parent Layout
parentLayout.arrangement().makeViews(in: rootView)

// Create Embedded Layout
XCTAssertNotNil(parentView)
singleViewArrangement.makeViews(in: parentView)

// Re-create Parent Layout
parentLayout.arrangement().makeViews(in: rootView)

XCTAssertEqual(parentView?.subviews.count, 1)
}

func testRemovesSubviewsForEmbeddedLayoutWithoutReuseId() {
var parentView: View?
let parentLayout = SizeLayout(minSize: .zero, config: { view in
parentView = view
})

// Create Parent Layout
parentLayout.arrangement().makeViews(in: rootView)

// Create Embedded Layout
XCTAssertNotNil(parentView)
singleViewArrangement.makeViews(in: parentView)
let originalParentView = parentView

// Re-create Parent Layout
parentLayout.arrangement().makeViews(in: rootView)

XCTAssertNotEqual(originalParentView, parentView)
XCTAssertEqual(parentView?.subviews.count, 0)
}

func testEmbeddedLayoutsAreRemoved() {
var originalHostView: View?
SizeLayout(
minSize: .zero,
viewReuseId: "foo",
sublayout: SizeLayout(minSize: .zero, viewReuseId: "bar", config: { view in
originalHostView = view
self.singleViewArrangement.makeViews(in: view)
}),
config: { _ in }).arrangement().makeViews(in: rootView)

XCTAssertEqual(rootView.subviews.count, 1)
XCTAssertNotNil(originalHostView)
XCTAssertEqual(originalHostView?.subviews.count, 1)

var updatedHostView: View?
SizeLayout(
minSize: .zero,
viewReuseId: "foo",
sublayout: SizeLayout(minSize: .zero, viewReuseId: "baz", config: { view in
updatedHostView = view
}),
config: { _ in }).arrangement().makeViews(in: rootView)

XCTAssertEqual(rootView.subviews.count, 1)
XCTAssertNotNil(updatedHostView)
XCTAssertEqual(updatedHostView?.subviews.count, 0)
XCTAssertNotEqual(originalHostView, updatedHostView)
}
}
14 changes: 14 additions & 0 deletions LayoutKitTests/LayoutArrangementTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,20 @@ class LayoutArrangementTests: XCTestCase {
XCTAssertEqual(view.subviews[1].frame, CGRect(x: 80, y: 10, width: 50, height: 50))
}

func testMakeViewsDifferentCalls() {
var createdView: View?
let sublayout0 = SizeLayout<View>(width: 50, height: 50, viewReuseId: "someID" ) { view in createdView = view }
let sublayout1 = SizeLayout<View>(width: 50, height: 50, viewReuseId: "otherID" ) { _ in }
let stackLayout = StackLayout(axis: .vertical, sublayouts: [sublayout0, sublayout1])
let arrangement = stackLayout.arrangement()
let hostView = View()
hostView.addSubview(arrangement.makeViews())
let firstView = createdView
arrangement.makeViews(in: hostView)
let secondView = createdView
XCTAssertTrue(firstView == secondView)
}

func testSubviewOrderIsStable() {
// Forces the SizeLayout to produce a view.
let forceViewConfig: (View) -> Void = { _ in }
Expand Down
51 changes: 47 additions & 4 deletions LayoutKitTests/ViewRecyclerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class ViewRecyclerTests: XCTestCase {
func testNilIdNotRecycledAndNotRemoved() {
let root = View()
let zero = View()
zero.isLayoutKitView = false // default
zero.type = .unmanaged // default
root.addSubview(zero)

let recycler = ViewRecycler(rootView: root)
Expand All @@ -25,13 +25,13 @@ class ViewRecyclerTests: XCTestCase {
XCTAssertEqual(v, expectedView)

recycler.purgeViews()
XCTAssertNotNil(zero.superview, "`zero` should not be removed because `isLayoutKitView` is false")
XCTAssertNotNil(zero.superview, "`zero` should not be removed because `type` is unmanaged")
}

func testNilIdNotRecycledAndRemoved() {
let root = View()
let zero = View()
zero.isLayoutKitView = true // requires this flag to be removed by `ViewRecycler`
zero.type = .managed // requires this flag to be removed by `ViewRecycler`
root.addSubview(zero)

let recycler = ViewRecycler(rootView: root)
Expand All @@ -42,7 +42,7 @@ class ViewRecyclerTests: XCTestCase {
XCTAssertEqual(v, expectedView)

recycler.purgeViews()
XCTAssertNil(zero.superview, "`zero` should be removed because `isLayoutKitView` is true")
XCTAssertNil(zero.superview, "`zero` should be removed because `type` is managed")
}

func testNonNilIdRecycled() {
Expand Down Expand Up @@ -91,6 +91,49 @@ class ViewRecyclerTests: XCTestCase {
XCTAssertNotNil(one.superview)
}

func testRootSubviewsMarkedAsManaged() {
let root = View()
let one = View(viewReuseId: "1")
one.type = .root
root.addSubview(one)
let two = View(viewReuseId: "2")
two.type = .root
one.addSubview(two)

let _ = ViewRecycler(rootView: root)

XCTAssertEqual(one.type, .managed)
XCTAssertEqual(two.type, .root)
}

func testDoesNotRecycleRootViews() {
let root = View()
let one = View(viewReuseId: "1")
one.type = .root
root.addSubview(one)
let two = View(viewReuseId: "2")
two.type = .root
one.addSubview(two)

let recycler = ViewRecycler(rootView: root)

// Reuse one so it is not purged from the view hierarchy
_ = recycler.makeOrRecycleView(havingViewReuseId: "1", viewProvider: {
XCTFail("view should have been recycled")
return View()
})

let expectedView = View()
let v: View? = recycler.makeOrRecycleView(havingViewReuseId: "2", viewProvider: {
return expectedView
})
XCTAssertEqual(v, expectedView)

recycler.purgeViews()
XCTAssertNotNil(one.superview)
XCTAssertNotNil(two.superview)
}

#if os(iOS) || os(tvOS)
/// Test that a reused view's frame shouldn't change if its transform and layer anchor point
/// get set to the default values.
Expand Down
8 changes: 8 additions & 0 deletions Sources/LayoutArrangement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,14 @@ public struct LayoutArrangement {
view.addSubview(subview, maintainCoordinates: prepareAnimation)
}
rootView = view
// In this case, the `rootView` is the view that was passed in. It is not created for this layout arrangement
// but merely hosts it. Therefore, the subview(s) that are being added to it are the root-most views from
// the LayoutKit view recycling perspective.
recycler.markViewsAsRoot(views)
} else if let view = views.first, views.count == 1 {
// We have a single view so it is our root view.
rootView = view
recycler.markViewsAsRoot(views)
} else {
// We have multiple views so create a root view.
rootView = View(frame: frame)
Expand All @@ -93,6 +98,9 @@ public struct LayoutArrangement {
}
rootView.addSubview(subview)
}
// The generated root view that's being returned is the root-most one that is created by LayoutKit,
// so it is the one that should be marked as the root by the recycler.
recycler.markViewsAsRoot([rootView])
}
recycler.purgeViews()

Expand Down
49 changes: 37 additions & 12 deletions Sources/ViewRecycler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import UIKit
Initialize ViewRecycler with a root view whose subviews are eligible for recycling.
Call `makeView(layoutId:)` to recycle or create a view of the desired type and id.
Call `purgeViews()` to remove all unrecycled views from the view hierarchy.
Call `markViewsAsRoot(views:)` to mark the top level views of generated view hierarchy
*/
class ViewRecycler {

Expand All @@ -30,7 +31,17 @@ class ViewRecycler {

/// Retains all subviews of rootView for recycling.
init(rootView: View?) {
rootView?.walkSubviews { (view) in
guard let rootView = rootView else {
return
}

// Mark all direct subviews from rootView as managed.
// We are recreating the layout they were previously roots of.
for view in rootView.subviews where view.type == .root {
view.type = .managed
}

rootView.walkNonRootSubviews { (view) in
if let viewReuseId = view.viewReuseId {
self.viewsById[viewReuseId] = view
} else {
Expand Down Expand Up @@ -77,7 +88,7 @@ class ViewRecycler {
}

let providedView = viewProvider()
providedView.isLayoutKitView = true
providedView.type = .managed

// Remove the provided view from the list of cached views.
if let viewReuseId = providedView.viewReuseId, let oldView = viewsById[viewReuseId], oldView == providedView {
Expand All @@ -96,23 +107,37 @@ class ViewRecycler {
}
viewsById.removeAll()

for view in unidentifiedViews where view.isLayoutKitView {
for view in unidentifiedViews where view.type == .managed {
view.removeFromSuperview()
}
unidentifiedViews.removeAll()
}

func markViewsAsRoot(_ views: [View]) {
views.forEach { $0.type = .root }
}
}

private var viewReuseIdKey: UInt8 = 0
private var isLayoutKitViewKey: UInt8 = 0
private var typeKey: UInt8 = 0

extension View {

enum ViewType: UInt8 {
// Indicates the view was not created by LayoutKit and should not be modified.
case unmanaged
// Indicates the view is managed by LayoutKit that can be safely removed.
case managed
// Indicates the view is managed by LayoutKit and is a root of a view hierarchy instantiated (or updated) by `makeViews`.
// Used to separate such nested hierarchies so that updating the outer hierarchy doesn't disturb any nested hierarchies.
case root
}

/// Calls visitor for each transitive subview.
func walkSubviews(visitor: (View) -> Void) {
for subview in subviews {
func walkNonRootSubviews(visitor: (View) -> Void) {
for subview in subviews where subview.type != .root {
visitor(subview)
subview.walkSubviews(visitor: visitor)
subview.walkNonRootSubviews(visitor: visitor)
}
}

Expand All @@ -122,17 +147,17 @@ extension View {
return objc_getAssociatedObject(self, &viewReuseIdKey) as? String
}
set {
objc_setAssociatedObject(self, &viewReuseIdKey, newValue, .OBJC_ASSOCIATION_RETAIN)
objc_setAssociatedObject(self, &viewReuseIdKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC)
}
}

/// Indicates the view is managed by LayoutKit that can be safely removed.
var isLayoutKitView: Bool {
var type: ViewType {
get {
return (objc_getAssociatedObject(self, &isLayoutKitViewKey) as? NSNumber)?.boolValue ?? false
return objc_getAssociatedObject(self, &typeKey) as? ViewType ?? .unmanaged
}
set {
objc_setAssociatedObject(self, &isLayoutKitViewKey, NSNumber(value: newValue), .OBJC_ASSOCIATION_RETAIN)
let type: ViewType? = (newValue == .unmanaged) ? nil : newValue
objc_setAssociatedObject(self, &typeKey, type, .OBJC_ASSOCIATION_COPY_NONATOMIC)
}
}
}

0 comments on commit f72c4df

Please sign in to comment.