Skip to content

[feat]: Add support for automatic ViewEnvironment bridging in WorkflowUI #211

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

Merged
merged 14 commits into from
May 31, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
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
1 change: 1 addition & 0 deletions Samples/TicTacToe/Sources/Authentication/LoginScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct LoginScreen: Screen {

func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
return ViewControllerDescription(
environment: environment,
build: { LoginViewController() },
update: { $0.update(with: self) }
)
Expand Down
1 change: 1 addition & 0 deletions WorkflowUI.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Pod::Spec.new do |s|

s.dependency 'Workflow', "#{s.version}"
s.dependency 'ViewEnvironment', "#{s.version}"
s.dependency 'ViewEnvironmentUI', "#{s.version}"

s.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'YES' }

Expand Down
52 changes: 43 additions & 9 deletions WorkflowUI/Sources/Hosting/WorkflowHostingController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@

import ReactiveSwift
import UIKit
@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI
import Workflow

/// Drives view controllers from a root Workflow.
public final class WorkflowHostingController<ScreenType, Output>: UIViewController where ScreenType: Screen {
public typealias CustomizeEnvironment = (inout ViewEnvironment) -> Void
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this alias be nested under the generic parent type? the fully-specified spelling will be parametrized by the screen/output, which might be unwieldy. then again, maybe nobody will need to reference it in that manner?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'd be surprised if we ever seen folks referencing the type directly in the context of WorkflowHostingController usage.

I can move it out if we'd prefer though! There are a few opportunities in Market to re-use this type if it was at the top level. Let me know if you'd prefer we do move it out, and if so, what to name it/where to put it!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

your call to relocate/rename if you think it makes sense – just a thought!


/// Emits output events from the bound workflow.
public var output: Signal<Output, Never> {
return workflowHost.output
Expand All @@ -33,31 +36,38 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll

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

public var rootViewEnvironment: ViewEnvironment {
didSet {
update(screen: workflowHost.rendering.value, environment: rootViewEnvironment)
}
public var customizeEnvironment: CustomizeEnvironment {
didSet { setNeedsEnvironmentUpdate() }
}

public init<W: AnyWorkflowConvertible>(
workflow: W,
rootViewEnvironment: ViewEnvironment = .empty,
customizeEnvironment: @escaping CustomizeEnvironment = { _ in },
observers: [WorkflowObserver] = []
) where W.Rendering == ScreenType, W.Output == Output {
self.workflowHost = WorkflowHost(
workflow: workflow.asAnyWorkflow(),
observers: observers
)

self.customizeEnvironment = customizeEnvironment

var customizedEnvironment: ViewEnvironment = .empty
customizeEnvironment(&customizedEnvironment)

self.rootViewController = workflowHost
.rendering
.value
.buildViewController(in: rootViewEnvironment)

self.rootViewEnvironment = rootViewEnvironment
.viewControllerDescription(environment: customizedEnvironment)
.buildViewController()

super.init(nibName: nil, bundle: nil)

// Do not automatically forward environment did change notifications to the rendered screen's backing view
// controller. Instead rely on `ViewControllerDescription` to call `setNeedsEnvironmentUpdate()` when updates
// occur.
environmentDescendantsOverride = { [] }

addChild(rootViewController)
rootViewController.didMove(toParent: self)

Expand All @@ -68,7 +78,7 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
.observeValues { [weak self] screen in
guard let self = self else { return }

self.update(screen: screen, environment: self.rootViewEnvironment)
self.update(screen: screen, environment: self.environment)
}
}

Expand All @@ -82,8 +92,17 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
}

private func update(screen: ScreenType, environment: ViewEnvironment) {
let previousRoot = rootViewController

update(child: \.rootViewController, with: screen, in: environment)

if previousRoot !== rootViewController {
// If a new view controller was instantiated and added as a child we need to inform it that the environment
// should be re-requested in order to respond to customizations in this WorkflowHostingController or any
// view controller above it in the UIViewController hierarchy.
setNeedsEnvironmentUpdate()
}

updatePreferredContentSizeIfNeeded()
}

Expand All @@ -98,6 +117,11 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
updatePreferredContentSizeIfNeeded()
}

public override func viewWillLayoutSubviews() {
super.viewWillLayoutSubviews()
applyEnvironmentIfNeeded()
}

override public func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
rootViewController.view.frame = view.bounds
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated but we should move this to viewWillLayoutSubviews

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fairly confident we should but I was nervous to make this change as part of this work.

Let me know if you think it's worth doing now!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah, it's less risky to isolate that to a separate change. Just wanted to flag it.

Expand Down Expand Up @@ -150,4 +174,14 @@ public final class WorkflowHostingController<ScreenType, Output>: UIViewControll
}
}

extension WorkflowHostingController: ViewEnvironmentObserving {
public func customize(environment: inout ViewEnvironment) {
customizeEnvironment(&environment)
}

public func environmentDidChange() {
update(screen: workflowHost.rendering.value, environment: environment)
}
}

#endif
1 change: 1 addition & 0 deletions WorkflowUI/Sources/ModuleExports.swift
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
@_exported import ViewEnvironment
@_exported import ViewEnvironmentUI
17 changes: 10 additions & 7 deletions WorkflowUI/Sources/Screen/ScreenViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
#if canImport(UIKit)

import UIKit
import ViewEnvironment
@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI

/// Generic base class that can be subclassed in order to to define a UI implementation that is powered by the
/// given screen type.
Expand All @@ -25,7 +27,7 @@ import UIKit
/// ```
/// struct MyScreen: Screen {
/// func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
/// return MyScreenViewController.description(for: self)
/// return MyScreenViewController.description(for: self, environment: environment)
/// }
/// }
///
Expand All @@ -42,11 +44,11 @@ open class ScreenViewController<ScreenType: Screen>: UIViewController {
return ScreenType.self
}

public private(set) final var environment: ViewEnvironment
private var previousEnvironment: ViewEnvironment
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to store this at all? I don't see it being used

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's being used in update(screen:) below.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bikeshed: lastEnvironment or latestEnvironment? It's not exactly "previous" until an update happens.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already built into the public API as previousEnvironment in screenDidChange(from:,previousEnvironment:). I'd rather not change the public API and I think it's nice that this variable name matches the public API parameter it's it exists to support.

That being said, I don't feel super strongly! Let me know if you do.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already built into the public API as previousEnvironment in screenDidChange(from:,previousEnvironment:). I'd rather not change the public API and I think it's nice that this variable name matches the public API parameter it's it exists to support.

OK, so, my reasoning is that it's not the "previous" environment while it's being stored — it becomes the previous one during an update, when we read a new environment to replace it. Until then, it's just the latest one we observed. Not a big deal though.


public required init(screen: ScreenType, environment: ViewEnvironment) {
self.screen = screen
self.environment = environment
self.previousEnvironment = environment
super.init(nibName: nil, bundle: nil)
}

Expand All @@ -55,11 +57,11 @@ open class ScreenViewController<ScreenType: Screen>: UIViewController {
fatalError("init(coder:) has not been implemented")
}

public final func update(screen: ScreenType, environment: ViewEnvironment) {
public final func update(screen: ScreenType) {
let previousScreen = self.screen
self.screen = screen
let previousEnvironment = self.environment
self.environment = environment
let previousEnvironment = self.previousEnvironment
self.previousEnvironment = environment
screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment)
}

Expand All @@ -79,8 +81,9 @@ extension ScreenViewController {
ViewControllerDescription(
performInitialUpdate: performInitialUpdate,
type: self,
environment: environment,
build: { self.init(screen: screen, environment: environment) },
update: { $0.update(screen: screen, environment: environment) }
update: { $0.update(screen: screen) }
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
#if canImport(UIKit)

import UIKit
import ViewEnvironment
@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI

/// A ViewControllerDescription acts as a "recipe" for building and updating a specific `UIViewController`.
/// It describes how to _create_ and later _update_ a given view controller instance, without creating one
Expand Down Expand Up @@ -49,6 +51,7 @@ public struct ViewControllerDescription {
/// type changed.
public let kind: KindIdentifier

private let environment: ViewEnvironment
private let build: () -> UIViewController
private let update: (UIViewController) -> Void

Expand All @@ -59,6 +62,11 @@ public struct ViewControllerDescription {
/// - performInitialUpdate: If an initial call to `update(viewController:)`
/// will be performed when the view controller is created. Defaults to `true`.
///
/// - environment: The `ViewEnvironment` that should be injected above the
/// described view controller for ViewEnvironmentUI environment propagation.
/// This is typically passed in from a `Screen` in its
/// `viewControllerDescription(environment:)` method.
///
/// - type: The type of view controller produced by this description.
/// Typically, should should be able to omit this parameter, but
/// in cases where type inference has trouble, it’s offered as
Expand All @@ -70,13 +78,16 @@ public struct ViewControllerDescription {
public init<VC: UIViewController>(
performInitialUpdate: Bool = true,
type: VC.Type = VC.self,
environment: ViewEnvironment,
build: @escaping () -> VC,
update: @escaping (VC) -> Void
) {
self.performInitialUpdate = performInitialUpdate

self.kind = .init(VC.self)

self.environment = environment

self.build = build

self.update = { untypedViewController in
Expand All @@ -95,7 +106,10 @@ public struct ViewControllerDescription {

if performInitialUpdate {
// Perform an initial update of the built view controller
// Note that this also configures the environment ancestor node.
update(viewController: viewController)
} else {
configureAncestor(for: viewController)
}

return viewController
Expand Down Expand Up @@ -126,8 +140,43 @@ public struct ViewControllerDescription {
"""
)

configureAncestor(for: viewController)

update(viewController)
}

private func configureAncestor(for viewController: UIViewController) {
guard let ancestorOverride = viewController.environmentAncestorOverride else {
// If no ancestor is currently present establish the initial ancestor override
establishAncestorOverride(for: viewController)
return
}

let currentAncestor = ancestorOverride()
// Check whether the VC's ancestor was overridden by a ViewControllerDescription.
guard currentAncestor is PropagationNode else {
// Do not override the VC's ancestor if it was overridden by something outside of the
// `ViewControllerDescription`'s management of this node.
// The view controller we're managing, or the container it's contained in, likely needs to manage this in a
// special way.
return
}

// We must nil this out first or we'll hit an assertion which protects against overriding the ancestor when
// some other system has already attempted to provide an override.
viewController.environmentAncestorOverride = nil
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, we could make PropagationNode a class and update the same instance. I'm not sure if there's a performance concern to updating the override on every render due to the associated object.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this is an optimization I was talking through with @bencochran. You think it's worth doing now?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think I'd lean towards just doing it now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please re-review if/when you have time 🙏

establishAncestorOverride(for: viewController)
}

private func establishAncestorOverride(for viewController: UIViewController) {
let ancestor = PropagationNode(
viewController: viewController,
environment: environment
)
viewController.environmentAncestorOverride = { ancestor }

ancestor.setNeedsEnvironmentUpdate()
}
}

extension ViewControllerDescription {
Expand Down Expand Up @@ -168,4 +217,39 @@ extension ViewControllerDescription {
}
}

extension ViewControllerDescription {
fileprivate struct PropagationNode: ViewEnvironmentObserving {

weak var viewController: UIViewController?

let environment: ViewEnvironment

init(
viewController: UIViewController,
environment: ViewEnvironment
) {
self.viewController = viewController
self.environment = environment
}

var environmentAncestor: ViewEnvironmentPropagating? { nil }

var environmentDescendants: [ViewEnvironmentPropagating] {
[viewController].compactMap { $0 }
}

func customize(environment: inout ViewEnvironment) {
environment = self.environment
}

func setNeedsEnvironmentUpdate() {
setNeedsEnvironmentUpdateOnAppropriateDescendants()
}

func applyEnvironmentIfNeeded() {
/// `apply(environment:)` is not implemented so do nothing.
}
}
}

#endif
2 changes: 2 additions & 0 deletions WorkflowUI/Tests/DescribedViewControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,14 @@ fileprivate enum TestScreen: Screen, Equatable {
switch self {
case .counter(let count):
return ViewControllerDescription(
environment: environment,
build: { CounterViewController(count: count) },
update: { $0.count = count }
)

case .message(let message):
return ViewControllerDescription(
environment: environment,
build: { MessageViewController(message: message) },
update: { $0.message = message }
)
Expand Down
2 changes: 2 additions & 0 deletions WorkflowUI/Tests/UIViewControllerExtensionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ private struct Screen1: Screen {
func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
ViewControllerDescription(
type: VC1.self,
environment: environment,
build: { VC1(identifier: "1", recordEvent: recordEvent) },
update: { $0.recordEvent = recordEvent }
)
Expand All @@ -167,6 +168,7 @@ private struct Screen2: Screen {
func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription {
ViewControllerDescription(
type: VC2.self,
environment: environment,
build: { VC2(identifier: "2", recordEvent: recordEvent) },
update: { $0.recordEvent = recordEvent }
)
Expand Down
Loading