Skip to content

Commit 14dd4eb

Browse files
authored
Merge pull request #153 from square/kve/flatten-described-vcs
Updates to make it easier to avoid using `DescribedViewController`, allowing flattening of VC hierarchies.
2 parents 6d098bd + 7b46aa4 commit 14dd4eb

10 files changed

+582
-21
lines changed

WorkflowUI.podspec

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ Pod::Spec.new do |s|
2525
test_spec.framework = 'XCTest'
2626
test_spec.library = 'swiftos'
2727
test_spec.dependency 'WorkflowReactiveSwift', "#{s.version}"
28+
29+
# Create an app host so that we can host
30+
# view or view controller based tests in a real environment.
31+
test_spec.requires_app_host = true
2832
end
2933
end
3034

WorkflowUI/Sources/Container/ContainerViewController.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,26 @@
2727
return workflowHost.output
2828
}
2929

30-
internal let rootViewController: DescribedViewController
30+
private(set) var rootViewController: UIViewController
3131

3232
private let workflowHost: WorkflowHost<RootWorkflow<ScreenType, Output>>
3333

3434
private let (lifetime, token) = Lifetime.make()
3535

3636
public var rootViewEnvironment: ViewEnvironment {
3737
didSet {
38-
// Re-render the current rendering with the new environment
39-
render(screen: workflowHost.rendering.value, environment: rootViewEnvironment)
38+
update(screen: workflowHost.rendering.value, environment: rootViewEnvironment)
4039
}
4140
}
4241

4342
public init<W: AnyWorkflowConvertible>(workflow: W, rootViewEnvironment: ViewEnvironment = .empty) where W.Rendering == ScreenType, W.Output == Output {
4443
self.workflowHost = WorkflowHost(workflow: RootWorkflow(workflow))
45-
self.rootViewController = DescribedViewController(screen: workflowHost.rendering.value, environment: rootViewEnvironment)
44+
45+
self.rootViewController = workflowHost
46+
.rendering
47+
.value
48+
.buildViewController(in: rootViewEnvironment)
49+
4650
self.rootViewEnvironment = rootViewEnvironment
4751

4852
super.init(nibName: nil, bundle: nil)
@@ -56,7 +60,8 @@
5660
.take(during: lifetime)
5761
.observeValues { [weak self] screen in
5862
guard let self = self else { return }
59-
self.render(screen: screen, environment: self.rootViewEnvironment)
63+
64+
self.update(screen: screen, environment: self.rootViewEnvironment)
6065
}
6166
}
6267

@@ -69,15 +74,18 @@
6974
fatalError("init(coder:) has not been implemented")
7075
}
7176

72-
private func render(screen: ScreenType, environment: ViewEnvironment) {
73-
rootViewController.update(screen: screen, environment: environment)
77+
private func update(screen: ScreenType, environment: ViewEnvironment) {
78+
update(child: \.rootViewController, with: screen, in: environment)
79+
80+
updatePreferredContentSizeIfNeeded()
7481
}
7582

7683
override public func viewDidLoad() {
7784
super.viewDidLoad()
7885

7986
view.backgroundColor = .white
8087

88+
rootViewController.view.frame = view.bounds
8189
view.addSubview(rootViewController.view)
8290

8391
updatePreferredContentSizeIfNeeded()

WorkflowUI/Sources/Screen/Screen.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
#if canImport(UIKit)
1818

19+
import UIKit
20+
1921
/// Screens are the building blocks of an interactive application.
2022
///
2123
/// Conforming types contain any information needed to populate a screen: data,
@@ -26,4 +28,28 @@
2628
func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription
2729
}
2830

31+
extension Screen {
32+
/// If the given view controller is of the correct type to be updated by this screen.
33+
///
34+
/// If your view controller type can change between updates, call this method before invoking `update(viewController:with:)`.
35+
public func canUpdate(viewController: UIViewController, with environment: ViewEnvironment) -> Bool {
36+
viewControllerDescription(environment: environment).canUpdate(viewController: viewController)
37+
}
38+
39+
/// Update the given view controller with the content from the screen.
40+
///
41+
/// ### Note
42+
/// You must pass a view controller previously created by a compatible `ViewControllerDescription`
43+
/// that passes `canUpdate(viewController:with:)`. Failure to do so will result in a fatal precondition.
44+
public func update(viewController: UIViewController, with environment: ViewEnvironment) {
45+
viewControllerDescription(environment: environment).update(viewController: viewController)
46+
}
47+
48+
/// Construct and update a new view controller as described by this Screen.
49+
/// The view controller will be updated before it is returned, so it is fully configured and prepared for display.
50+
public func buildViewController(in environment: ViewEnvironment) -> UIViewController {
51+
viewControllerDescription(environment: environment).buildViewController()
52+
}
53+
}
54+
2955
#endif

WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@
5757
}
5858

5959
currentViewController.didMove(toParent: self)
60+
61+
updatePreferredContentSizeIfNeeded()
6062
}
6163
}
6264

@@ -67,6 +69,7 @@
6769
override public func viewDidLoad() {
6870
super.viewDidLoad()
6971

72+
currentViewController.view.frame = view.bounds
7073
view.addSubview(currentViewController.view)
7174

7275
updatePreferredContentSizeIfNeeded()
@@ -124,5 +127,4 @@
124127
preferredContentSize = newPreferredContentSize
125128
}
126129
}
127-
128130
#endif
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright 2020 Square Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#if canImport(UIKit)
18+
19+
import UIKit
20+
21+
extension UpdateChildScreenViewController where Self: UIViewController {
22+
/// Updates the view controller at the given `child` key path with the
23+
/// `ViewControllerDescription` from `screen`. If the type of the underlying view
24+
/// controller changes between update passes, this method will remove
25+
/// the old view controller, create a new one, update it, and insert it into the view controller hierarchy.
26+
///
27+
/// The view controller at `child` must be a child of `self`.
28+
///
29+
/// - Parameters:
30+
/// - parameter child: The `KeyPath` which describes what view controller to update. This view controller must be a direct child of `self`.
31+
/// - parameter screen: The `Screen` instance to apply to the view controller.
32+
/// - parameter environment: The `environment` to used when updating the view controller.
33+
/// - parameter onChange: A callback called if the view controller instance changed.
34+
///
35+
public func update<VC: UIViewController, ScreenType: Screen>(
36+
child: ReferenceWritableKeyPath<Self, VC>,
37+
with screen: ScreenType,
38+
in environment: ViewEnvironment,
39+
onChange: (VC) -> Void = { _ in }
40+
) {
41+
let description = screen.viewControllerDescription(environment: environment)
42+
43+
let existing = self[keyPath: child]
44+
45+
if description.canUpdate(viewController: existing) {
46+
// Easy path: Just update the existing view controller if we can do that.
47+
description.update(viewController: existing)
48+
} else {
49+
// If we can't update the view controller, that means its type changed.
50+
// We'll need to make a new view controller and swap over to it.
51+
52+
let old = existing
53+
54+
// Make the new view controller.
55+
56+
let new = description.buildViewController() as! VC
57+
58+
// We already have a reference to the old vc above, update the keypath to the new one.
59+
60+
self[keyPath: child] = new
61+
62+
// We should only add the view controller if the old one was already within the parent.
63+
64+
if let parent = old.parent {
65+
precondition(
66+
parent == self,
67+
"""
68+
The parent of the child view controller must be \(self). Instead, it was \(parent). \
69+
Please call `update(child:)` on the correct parent view controller.
70+
"""
71+
)
72+
73+
// Begin the transition: Signal the new vc will begin moving in, and the old one, out.
74+
75+
parent.addChild(new)
76+
old.willMove(toParent: nil)
77+
78+
if
79+
parent.isViewLoaded,
80+
old.isViewLoaded,
81+
let container = old.view.superview {
82+
// We will only add the view to the hierarchy if
83+
// the parent's view is loaded, and the existing view
84+
// is loaded, and the old view was in a superview.
85+
86+
// We will only perform appearance transitions if we're visible.
87+
88+
let isVisible = parent.view.window != nil
89+
90+
// The view should end up with the same frame.
91+
92+
new.view.frame = old.view.frame
93+
94+
if isVisible {
95+
new.beginAppearanceTransition(true, animated: false)
96+
old.beginAppearanceTransition(false, animated: false)
97+
}
98+
99+
container.insertSubview(new.view, aboveSubview: old.view)
100+
old.view.removeFromSuperview()
101+
102+
if isVisible {
103+
new.endAppearanceTransition()
104+
old.endAppearanceTransition()
105+
}
106+
}
107+
108+
// Finish the transition by signaling the vc they've fully moved in / out.
109+
110+
new.didMove(toParent: parent)
111+
old.removeFromParent()
112+
}
113+
114+
onChange(new)
115+
}
116+
}
117+
}
118+
119+
public protocol UpdateChildScreenViewController {}
120+
121+
extension UIViewController: UpdateChildScreenViewController {}
122+
123+
#endif

WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,15 @@
4141
/// When creating container view controllers that contain other view controllers
4242
/// (eg, a navigation stack), you usually want to set this value to `false` to avoid
4343
/// duplicate updates to your children if they are created in `init`.
44-
public var performInitialUpdate: Bool = true
44+
public var performInitialUpdate: Bool
45+
46+
/// Describes the `UIViewController` type that backs the `ViewControllerDescription`
47+
/// in a way that is `Equatable` and `Hashable`. When implementing view controller
48+
/// updating and diffing, you can use this type to identify if the backing view controller
49+
/// type changed.
50+
public let kind: KindIdentifier
4551

46-
private let viewControllerType: UIViewController.Type
4752
private let build: () -> UIViewController
48-
private let checkViewControllerType: (UIViewController) -> Bool
4953
private let update: (UIViewController) -> Void
5054

5155
/// Constructs a view controller description by providing closures used to
@@ -70,13 +74,16 @@
7074
update: @escaping (VC) -> Void
7175
) {
7276
self.performInitialUpdate = performInitialUpdate
73-
self.viewControllerType = type
77+
78+
self.kind = .init(VC.self)
79+
7480
self.build = build
75-
self.checkViewControllerType = { $0 is VC }
81+
7682
self.update = { untypedViewController in
7783
guard let viewController = untypedViewController as? VC else {
7884
fatalError("Unable to update \(untypedViewController), expecting a \(VC.self)")
7985
}
86+
8087
update(viewController)
8188
}
8289
}
@@ -97,11 +104,8 @@
97104
/// If the given view controller is of the correct type to be updated by this view controller description.
98105
///
99106
/// If your view controller type can change between updates, call this method before invoking `update(viewController:)`.
100-
///
101-
/// ### Note
102-
/// Failure to confirm the view controller is updatable will result in a fatal `precondition`.
103107
public func canUpdate(viewController: UIViewController) -> Bool {
104-
return checkViewControllerType(viewController)
108+
kind.canUpdate(viewController: viewController)
105109
}
106110

107111
/// Update the given view controller with the content from the view controller description.
@@ -118,12 +122,50 @@
118122
"""
119123
`ViewControllerDescription` was provided a view controller it cannot update: (\(viewController).
120124
121-
The view controller type (\(type(of: viewController)) is a compatible type to the expected type \(viewControllerType)).
125+
The view controller type (\(type(of: viewController)) is a compatible type to the expected type \(kind.viewControllerType)).
122126
"""
123127
)
124128

125129
update(viewController)
126130
}
127131
}
128132

133+
extension ViewControllerDescription {
134+
/// Describes the `UIViewController` type that backs the `ViewControllerDescription`
135+
/// in a way that is `Equatable` and `Hashable`. When implementing view controller
136+
/// updating and diffing, you can use this type to identify if the backing view controller
137+
/// type changed.
138+
public struct KindIdentifier: Hashable {
139+
fileprivate let viewControllerType: UIViewController.Type
140+
141+
private let checkViewControllerType: (UIViewController) -> Bool
142+
143+
/// Creates a new kind for the given view controller type.
144+
public init<VC: UIViewController>(_ kind: VC.Type) {
145+
self.viewControllerType = VC.self
146+
147+
self.checkViewControllerType = { $0 is VC }
148+
}
149+
150+
/// If the given view controller is of the correct type to be updated by this view controller description.
151+
///
152+
/// If your view controller type can change between updates, call this method before invoking `update(viewController:)`.
153+
public func canUpdate(viewController: UIViewController) -> Bool {
154+
return checkViewControllerType(viewController)
155+
}
156+
157+
// MARK: Hashable
158+
159+
public func hash(into hasher: inout Hasher) {
160+
hasher.combine(ObjectIdentifier(viewControllerType))
161+
}
162+
163+
// MARK: Equatable
164+
165+
public static func == (lhs: Self, rhs: Self) -> Bool {
166+
lhs.viewControllerType == rhs.viewControllerType
167+
}
168+
}
169+
}
170+
129171
#endif

WorkflowUI/Tests/ContainerViewControllerTests.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
let container = ContainerViewController(workflow: workflow)
4848

4949
withExtendedLifetime(container) {
50-
let vc = container.rootViewController.currentViewController as! TestScreenViewController
50+
let vc = container.rootViewController as! TestScreenViewController
5151
XCTAssertEqual("0", vc.screen.string)
5252
}
5353
}
@@ -60,7 +60,7 @@
6060
withExtendedLifetime(container) {
6161
let expectation = XCTestExpectation(description: "View Controller updated")
6262

63-
let vc = container.rootViewController.currentViewController as! TestScreenViewController
63+
let vc = container.rootViewController as! TestScreenViewController
6464
vc.onScreenChange = {
6565
expectation.fulfill()
6666
}
@@ -118,7 +118,7 @@
118118
withExtendedLifetime(container) {
119119
let expectation = XCTestExpectation(description: "View Controller updated")
120120

121-
let vc = container.rootViewController.currentViewController as! TestScreenViewController
121+
let vc = container.rootViewController as! TestScreenViewController
122122

123123
XCTAssertEqual("first", vc.screen.string)
124124

0 commit comments

Comments
 (0)