Skip to content

Commit 8907b6a

Browse files
committed
Make it easier to directly manage screen-backed UIViewControllers directly to remove DescribedViewController layering from VC hierarchies.
1 parent 1ee49f2 commit 8907b6a

15 files changed

+276
-25
lines changed

Workflow.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
1313
s.cocoapods_version = '>= 1.7.0'
1414

1515
s.swift_versions = ['5.0']
16-
s.ios.deployment_target = '11.0'
16+
s.ios.deployment_target = '12.0'
1717
s.osx.deployment_target = '10.13'
1818

1919
s.source_files = 'Workflow/Sources/*.swift'

WorkflowReactiveSwift.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
1313
s.cocoapods_version = '>= 1.7.0'
1414

1515
s.swift_versions = ['5.0']
16-
s.ios.deployment_target = '11.0'
16+
s.ios.deployment_target = '12.0'
1717
s.osx.deployment_target = '10.13'
1818

1919
s.source_files = 'WorkflowReactiveSwift/Sources/**/*.swift'

WorkflowReactiveSwiftTesting.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
1313
s.cocoapods_version = '>= 1.7.0'
1414

1515
s.swift_versions = ['5.0']
16-
s.ios.deployment_target = '11.0'
16+
s.ios.deployment_target = '12.0'
1717
s.osx.deployment_target = '10.13'
1818

1919
s.source_files = 'WorkflowReactiveSwift/Testing/**/*.swift'

WorkflowRxSwift.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
1313
s.cocoapods_version = '>= 1.7.0'
1414

1515
s.swift_versions = ['5.0']
16-
s.ios.deployment_target = '11.0'
16+
s.ios.deployment_target = '12.0'
1717
s.osx.deployment_target = '10.13'
1818

1919
s.source_files = 'WorkflowRxSwift/Sources/**/*.swift'

WorkflowRxSwiftTesting.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
1313
s.cocoapods_version = '>= 1.7.0'
1414

1515
s.swift_versions = ['5.0']
16-
s.ios.deployment_target = '11.0'
16+
s.ios.deployment_target = '12.0'
1717
s.osx.deployment_target = '10.13'
1818

1919
s.source_files = 'WorkflowRxSwift/Testing/**/*.swift'

WorkflowTesting.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
1313
s.cocoapods_version = '>= 1.7.0'
1414

1515
s.swift_versions = ['5.0']
16-
s.ios.deployment_target = '11.0'
16+
s.ios.deployment_target = '12.0'
1717
s.osx.deployment_target = '10.13'
1818

1919
s.source_files = 'WorkflowTesting/Sources/**/*.swift'

WorkflowUI.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
1313
s.cocoapods_version = '>= 1.7.0'
1414

1515
s.swift_versions = ['5.0']
16-
s.ios.deployment_target = '11.0'
16+
s.ios.deployment_target = '12.0'
1717
s.osx.deployment_target = '10.13'
1818

1919
s.source_files = 'WorkflowUI/Sources/**/*.swift'

WorkflowUI/Sources/Container/ContainerViewController.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,27 @@
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+
.viewControllerDescription(environment: rootViewEnvironment)
49+
.buildViewController()
50+
4651
self.rootViewEnvironment = rootViewEnvironment
4752

4853
super.init(nibName: nil, bundle: nil)
@@ -56,7 +61,8 @@
5661
.take(during: lifetime)
5762
.observeValues { [weak self] screen in
5863
guard let self = self else { return }
59-
self.render(screen: screen, environment: self.rootViewEnvironment)
64+
65+
self.update(screen: screen, environment: self.rootViewEnvironment)
6066
}
6167
}
6268

@@ -69,8 +75,10 @@
6975
fatalError("init(coder:) has not been implemented")
7076
}
7177

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

7684
override public func viewDidLoad() {

WorkflowUI/Sources/Screen/Screen.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,10 @@
2626
func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription
2727
}
2828

29+
extension Screen {
30+
public func update(viewController: UIViewController, with environment: ViewEnvironment) {
31+
viewControllerDescription(environment: environment).update(viewController: viewController)
32+
}
33+
}
34+
2935
#endif

WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,5 +124,4 @@
124124
preferredContentSize = newPreferredContentSize
125125
}
126126
}
127-
128127
#endif
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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 it's 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+
// We only send appearance transitions if the parent won't do it
91+
// for us on insert of the added subview.
92+
93+
let sendsAppearanceTransitions = isVisible && parent.shouldAutomaticallyForwardAppearanceMethods == false
94+
95+
// The view should end up with the same frame.
96+
97+
new.view.frame = old.view.frame
98+
99+
container.insertSubview(new.view, aboveSubview: old.view)
100+
101+
if sendsAppearanceTransitions {
102+
new.beginAppearanceTransition(true, animated: false)
103+
old.beginAppearanceTransition(false, animated: false)
104+
}
105+
106+
old.view.removeFromSuperview()
107+
108+
if sendsAppearanceTransitions {
109+
new.endAppearanceTransition()
110+
old.endAppearanceTransition()
111+
}
112+
}
113+
114+
// Finish the transition by signaling the vc they've fully moved in / out.
115+
116+
new.didMove(toParent: parent)
117+
old.removeFromParent()
118+
}
119+
120+
onChange(new)
121+
}
122+
}
123+
}
124+
125+
public protocol UpdateChildScreenViewController {}
126+
127+
extension UIViewController: UpdateChildScreenViewController {}
128+
129+
#endif

WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift

Lines changed: 52 additions & 7 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
}
@@ -101,7 +108,7 @@
101108
/// ### Note
102109
/// Failure to confirm the view controller is updatable will result in a fatal `precondition`.
103110
public func canUpdate(viewController: UIViewController) -> Bool {
104-
return checkViewControllerType(viewController)
111+
kind.canUpdate(viewController: viewController)
105112
}
106113

107114
/// Update the given view controller with the content from the view controller description.
@@ -118,12 +125,50 @@
118125
"""
119126
`ViewControllerDescription` was provided a view controller it cannot update: (\(viewController).
120127
121-
The view controller type (\(type(of: viewController)) is a compatible type to the expected type \(viewControllerType)).
128+
The view controller type (\(type(of: viewController)) is a compatible type to the expected type \(kind.viewControllerType)).
122129
"""
123130
)
124131

125132
update(viewController)
126133
}
127134
}
128135

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