Skip to content

Commit 7fea79f

Browse files
authored
[fix]: update WorkflowHostingController on layout if ancestor hierarchy has changed (#227)
1 parent 95ddd1e commit 7fea79f

File tree

3 files changed

+138
-5
lines changed

3 files changed

+138
-5
lines changed

ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,65 @@ extension ViewEnvironmentPropagating {
272272
objc_setAssociatedObject(self, &AssociatedKeys.descendantsOverride, newValue, .OBJC_ASSOCIATION_RETAIN)
273273
}
274274
}
275+
276+
/// Returns an `Equatable` representation of the `ViewEnvironmentPropagating` environment
277+
/// ancestor tree path.
278+
///
279+
/// This can be useful, for example, if you need to determine if any ancestor was inserted or
280+
/// removed above this node.
281+
///
282+
/// The `Equatable` implementation of this type compares the tree as an array of weak
283+
/// references.
284+
///
285+
@_spi(ViewEnvironmentWiring)
286+
public var environmentAncestorPath: EnvironmentAncestorPath {
287+
var path = EnvironmentAncestorPath()
288+
289+
if let first = environmentAncestor {
290+
for node in sequence(first: first, next: \.environmentAncestor) {
291+
path.append(node)
292+
}
293+
}
294+
295+
return path
296+
}
297+
298+
@_spi(ViewEnvironmentWiring)
299+
public typealias EnvironmentAncestorPath = ViewEnvironmentPropagatingAncestorPath
300+
}
301+
302+
/// An `Equatable` representation of the `ViewEnvironmentPropagating` environment ancestor tree
303+
/// path.
304+
///
305+
/// This can be useful, for example, if you need to determine if any ancestor was inserted or
306+
/// removed above this node.
307+
///
308+
/// The `Equatable` implementation of this type compares the tree as an array of weak references.
309+
///
310+
@_spi(ViewEnvironmentWiring)
311+
public struct ViewEnvironmentPropagatingAncestorPath: Equatable {
312+
private var nodes: [WeakBox] = []
313+
314+
fileprivate mutating func append(_ node: ViewEnvironmentPropagating) {
315+
nodes.append(WeakBox(node))
316+
}
317+
318+
// Use a weak box to avoid retaining the node.
319+
//
320+
// We do this instead of `ObjectIdentifier` because `ObjectIdentifier`s are only valid for the
321+
// lifetime of the object being identified—the value of the pointer could be re-used if it is
322+
// deallocated.
323+
private struct WeakBox: Equatable {
324+
weak var node: ViewEnvironmentPropagating?
325+
326+
init(_ node: ViewEnvironmentPropagating) {
327+
self.node = node
328+
}
329+
330+
static func == (lhs: WeakBox, rhs: WeakBox) -> Bool {
331+
lhs.node === rhs.node
332+
}
333+
}
275334
}
276335

277336
/// A closure that is called when the `ViewEnvironment` needs to be updated.

WorkflowUI/Sources/Hosting/WorkflowHostingController.swift

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ public final class WorkflowHostingController<ScreenType, Output>: WorkflowUIView
4141

4242
private let (lifetime, token) = Lifetime.make()
4343

44+
private var lastEnvironmentAncestorPath: EnvironmentAncestorPath?
45+
4446
public init<W: AnyWorkflowConvertible>(
4547
workflow: W,
4648
customizeEnvironment: @escaping CustomizeEnvironment = { _ in },
@@ -79,7 +81,10 @@ public final class WorkflowHostingController<ScreenType, Output>: WorkflowUIView
7981
.observeValues { [weak self] screen in
8082
guard let self = self else { return }
8183

82-
self.update(screen: screen, environment: self.environment)
84+
self.update(
85+
screen: screen,
86+
environmentAncestorPath: self.environmentAncestorPath
87+
)
8388
}
8489
}
8590

@@ -92,7 +97,10 @@ public final class WorkflowHostingController<ScreenType, Output>: WorkflowUIView
9297
fatalError("init(coder:) has not been implemented")
9398
}
9499

95-
private func update(screen: ScreenType, environment: ViewEnvironment) {
100+
private func update(screen: ScreenType, environmentAncestorPath: EnvironmentAncestorPath) {
101+
lastEnvironmentAncestorPath = environmentAncestorPath
102+
103+
let environment = environment
96104
let previousRoot = rootViewController
97105

98106
update(child: \.rootViewController, with: screen, in: environment)
@@ -120,7 +128,14 @@ public final class WorkflowHostingController<ScreenType, Output>: WorkflowUIView
120128

121129
override public func viewWillLayoutSubviews() {
122130
super.viewWillLayoutSubviews()
123-
applyEnvironmentIfNeeded()
131+
132+
let environmentAncestorPath = environmentAncestorPath
133+
if environmentAncestorPath != lastEnvironmentAncestorPath {
134+
update(
135+
screen: workflowHost.rendering.value,
136+
environmentAncestorPath: environmentAncestorPath
137+
)
138+
}
124139
}
125140

126141
override public func viewDidLayoutSubviews() {
@@ -181,7 +196,10 @@ extension WorkflowHostingController: ViewEnvironmentObserving {
181196
}
182197

183198
public func environmentDidChange() {
184-
update(screen: workflowHost.rendering.value, environment: environment)
199+
update(
200+
screen: workflowHost.rendering.value,
201+
environmentAncestorPath: environmentAncestorPath
202+
)
185203
}
186204
}
187205

WorkflowUI/Tests/WorkflowHostingControllerTests.swift

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,63 @@ class WorkflowHostingControllerTests: XCTestCase {
224224
XCTAssertEqual(environment[ScreenKey.self], true)
225225
}
226226
}
227+
228+
func test_environment_updates_on_layout_in_new_hierarchy() {
229+
var changedEnvironments: [ViewEnvironment] = []
230+
let hostingController = WorkflowHostingController(
231+
workflow: EnvironmentObservingWorkflow(
232+
value: "first",
233+
onEnvironmentDidChange: { changedEnvironments.append($0) }
234+
)
235+
)
236+
237+
// Setup the initial hierarchy
238+
let root1 = UIViewController()
239+
let container = UIViewController()
240+
root1.addChild(container)
241+
root1.view.addSubview(container.view)
242+
container.didMove(toParent: root1)
243+
244+
container.addChild(hostingController)
245+
container.view.addSubview(hostingController.view)
246+
hostingController.didMove(toParent: container)
247+
248+
XCTAssertEqual(changedEnvironments.count, 1)
249+
250+
// Triggering a layout should cause an update to the workflow's rendering since the
251+
// ancestor path has changed since the `WorkflowHostingController` was initialized
252+
hostingController.view.setNeedsLayout()
253+
hostingController.view.layoutIfNeeded()
254+
XCTAssertEqual(changedEnvironments.count, 2)
255+
256+
// There should be no environment update if the Workflow state and ancestor tree path has
257+
// not changed
258+
hostingController.view.setNeedsLayout()
259+
hostingController.view.layoutIfNeeded()
260+
XCTAssertEqual(changedEnvironments.count, 2)
261+
262+
// Change the environment ancestor path
263+
container.willMove(toParent: nil)
264+
container.view.removeFromSuperview()
265+
container.removeFromParent()
266+
267+
let root2 = UIViewController()
268+
root2.addChild(container)
269+
root2.view.addSubview(container.view)
270+
container.didMove(toParent: root2)
271+
272+
// An environment update should occur since the ancestor path has changed since the last
273+
// update and/or layout.
274+
hostingController.view.setNeedsLayout()
275+
hostingController.view.layoutIfNeeded()
276+
XCTAssertEqual(changedEnvironments.count, 3)
277+
278+
// There should be no environment update if the Workflow state and ancestor tree path has
279+
// not changed
280+
hostingController.view.setNeedsLayout()
281+
hostingController.view.layoutIfNeeded()
282+
XCTAssertEqual(changedEnvironments.count, 3)
283+
}
227284
}
228285

229286
fileprivate struct SubscribingWorkflow: Workflow {
@@ -303,7 +360,6 @@ fileprivate struct EnvironmentObservingWorkflow: Workflow {
303360
}
304361

305362
fileprivate final class EnvironmentCustomizingViewController: UIViewController, ViewEnvironmentObserving {
306-
307363
var customizeEnvironment: (inout ViewEnvironment) -> Void
308364

309365
init(customizeEnvironment: @escaping (inout ViewEnvironment) -> Void) {

0 commit comments

Comments
 (0)