diff --git a/Development.podspec b/Development.podspec index f76436835..d6ce1725e 100644 --- a/Development.podspec +++ b/Development.podspec @@ -18,6 +18,7 @@ Pod::Spec.new do |s| s.dependency 'WorkflowCombine' s.dependency 'WorkflowConcurrency' s.dependency 'ViewEnvironment' + s.dependency 'ViewEnvironmentUI' s.source_files = 'Samples/Dummy.swift' @@ -101,6 +102,12 @@ Pod::Spec.new do |s| } end + s.test_spec 'ViewEnvironmentUITests' do |test_spec| + test_spec.requires_app_host = true + test_spec.source_files = 'ViewEnvironmentUI/Tests/**/*.swift' + test_spec.framework = 'XCTest' + end + s.test_spec 'WorkflowTests' do |test_spec| test_spec.requires_app_host = true test_spec.source_files = 'Workflow/Tests/**/*.swift' diff --git a/Package.swift b/Package.swift index 8ec771305..ad63389ee 100644 --- a/Package.swift +++ b/Package.swift @@ -82,6 +82,13 @@ let package = Package( name: "ViewEnvironment", targets: ["ViewEnvironment"] ), + + // MARK: ViewEnvironmentUI + + .library( + name: "ViewEnvironmentUI", + targets: ["ViewEnvironmentUI"] + ), ], dependencies: [ .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "7.1.1"), @@ -116,7 +123,7 @@ let package = Package( .target( name: "WorkflowUI", - dependencies: ["Workflow", "ViewEnvironment"], + dependencies: ["Workflow", "ViewEnvironment", "ViewEnvironmentUI"], path: "WorkflowUI/Sources" ), .testTarget( @@ -228,6 +235,14 @@ let package = Package( name: "ViewEnvironment", path: "ViewEnvironment/Sources" ), + + // MARK: ViewEnvironmentUI + + .target( + name: "ViewEnvironmentUI", + dependencies: ["ViewEnvironment"], + path: "ViewEnvironmentUI/Sources" + ), ], swiftLanguageVersions: [.v5] ) diff --git a/RELEASING.md b/RELEASING.md index fe0f33ae3..117625f44 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -17,7 +17,7 @@ For Squares, membership is managed through the `Workflow Swift Owners` registry > ⚠️ [Optional] To avoid possible headaches when publishing podspecs, validation can be performed before updating the Workflow version number(s). To do this, run the following in the root directory of this repo: > ```bash -> bundle exec pod lib lint Workflow.podspec ViewEnvironment.podspec WorkflowTesting.podspec WorkflowReactiveSwift.podspec WorkflowUI.podspec WorkflowRxSwift.podspec WorkflowReactiveSwiftTesting.podspec WorkflowRxSwiftTesting.podspec WorkflowSwiftUI.podspec WorkflowCombine.podspec WorkflowCombineTesting.podspec WorkflowConcurrency.podspec WorkflowConcurrencyTesting.podspec +> bundle exec pod lib lint Workflow.podspec ViewEnvironment.podspec ViewEnvironmentUI.podspec WorkflowTesting.podspec WorkflowReactiveSwift.podspec WorkflowUI.podspec WorkflowRxSwift.podspec WorkflowReactiveSwiftTesting.podspec WorkflowRxSwiftTesting.podspec WorkflowSwiftUI.podspec WorkflowCombine.podspec WorkflowCombineTesting.podspec WorkflowConcurrency.podspec WorkflowConcurrencyTesting.podspec > ``` 1. Update `VERSION` file based on [`semver`](https://semver.org/). @@ -37,6 +37,7 @@ For Squares, membership is managed through the `Workflow Swift Owners` registry bundle exec pod trunk push WorkflowTesting.podspec --synchronous bundle exec pod trunk push WorkflowReactiveSwift.podspec --synchronous bundle exec pod trunk push ViewEnvironment.podspec --synchronous + bundle exec pod trunk push ViewEnvironmentUI.podspec --synchronous bundle exec pod trunk push WorkflowUI.podspec --synchronous bundle exec pod trunk push WorkflowRxSwift.podspec --synchronous bundle exec pod trunk push WorkflowReactiveSwiftTesting.podspec --synchronous diff --git a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift index 12ff38a80..c9298b058 100644 --- a/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift +++ b/Samples/TicTacToe/Sources/Authentication/LoginScreen.swift @@ -27,6 +27,7 @@ struct LoginScreen: Screen { func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { return ViewControllerDescription( + environment: environment, build: { LoginViewController() }, update: { $0.update(with: self) } ) diff --git a/Samples/Tutorial/Podfile b/Samples/Tutorial/Podfile index 91194286f..9409132e9 100644 --- a/Samples/Tutorial/Podfile +++ b/Samples/Tutorial/Podfile @@ -7,6 +7,7 @@ target 'Tutorial' do pod 'Workflow', path: '../../Workflow.podspec', :testspecs => ['Tests'] pod 'WorkflowUI', path: '../../WorkflowUI.podspec', :testspecs => ['Tests'] pod 'ViewEnvironment', path: '../../ViewEnvironment.podspec' + pod 'ViewEnvironmentUI', path: '../../ViewEnvironmentUI.podspec', :testspecs => ['Tests'] pod 'WorkflowReactiveSwift', path: '../../WorkflowReactiveSwift.podspec', :testspecs => ['Tests'] pod 'BackStackContainer', path: '../BackStackContainer/BackStackContainer.podspec' diff --git a/ViewEnvironmentUI.podspec b/ViewEnvironmentUI.podspec new file mode 100644 index 000000000..3206a2758 --- /dev/null +++ b/ViewEnvironmentUI.podspec @@ -0,0 +1,36 @@ +require_relative('version') + +Pod::Spec.new do |s| + s.name = 'ViewEnvironmentUI' + s.version = WORKFLOW_VERSION + s.summary = 'Provides a way to propagate a ViewEnvironment through an imperative hierarchy' + s.homepage = 'https://www.github.com/square/workflow-swift' + s.license = 'Apache License, Version 2.0' + s.author = 'Square' + s.source = { :git => 'https://github.com/square/workflow-swift.git', :tag => "v#{s.version}" } + + # 1.7 is needed for `swift_versions` support + s.cocoapods_version = '>= 1.7.0' + + s.swift_versions = [WORKFLOW_SWIFT_VERSION] + s.ios.deployment_target = WORKFLOW_IOS_DEPLOYMENT_TARGET + s.osx.deployment_target = WORKFLOW_MACOS_DEPLOYMENT_TARGET + + s.source_files = 'ViewEnvironmentUI/Sources/**/*.swift' + + s.dependency 'ViewEnvironment', "#{s.version}" + + s.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'YES' } + + s.test_spec 'Tests' do |test_spec| + test_spec.source_files = 'ViewEnvironmentUI/Tests/**/*.swift' + test_spec.framework = 'XCTest' + test_spec.library = 'swiftos' + + # Create an app host so that we can host + # view or view controller based tests in a real environment. + test_spec.requires_app_host = true + + test_spec.pod_target_xcconfig = { 'APPLICATION_EXTENSION_API_ONLY' => 'NO' } + end +end diff --git a/ViewEnvironmentUI/README.md b/ViewEnvironmentUI/README.md new file mode 100644 index 000000000..a0bbddabb --- /dev/null +++ b/ViewEnvironmentUI/README.md @@ -0,0 +1,6 @@ +# ViewEnvironmentUI + +`ViewEnvironmentUI` provides a means to propagate a `ViewEnvironment` through a hierarchy of object nodes. + +Support for propagation of `ViewEnvironment` through `UIViewController`s and `UIView`s is provided by this framework. + diff --git a/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagating.swift b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagating.swift new file mode 100644 index 000000000..6610776a8 --- /dev/null +++ b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagating.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2023 Square Inc. + * + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if canImport(UIKit) + +import UIKit +import ViewEnvironment + +extension UIView: ViewEnvironmentPropagating { + @_spi(ViewEnvironmentWiring) + public var defaultEnvironmentAncestor: ViewEnvironmentPropagating? { superview } + + @_spi(ViewEnvironmentWiring) + public var defaultEnvironmentDescendants: [ViewEnvironmentPropagating] { subviews } + + @_spi(ViewEnvironmentWiring) + public func setNeedsApplyEnvironment() { + setNeedsLayout() + } +} + +#endif diff --git a/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagating.swift b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagating.swift new file mode 100644 index 000000000..de3708177 --- /dev/null +++ b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagating.swift @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Square Inc. + * + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if canImport(UIKit) + +import UIKit +import ViewEnvironment + +extension UIViewController: ViewEnvironmentPropagating { + @_spi(ViewEnvironmentWiring) + public var defaultEnvironmentAncestor: ViewEnvironmentPropagating? { parent ?? presentingViewController } + + @_spi(ViewEnvironmentWiring) + public var defaultEnvironmentDescendants: [ViewEnvironmentPropagating] { + var descendants = children + + if let presentedViewController = presentedViewController { + descendants.append(presentedViewController) + } + + return descendants + } + + @_spi(ViewEnvironmentWiring) + public func setNeedsApplyEnvironment() { + viewIfLoaded?.setNeedsLayout() + } +} + +#endif diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift new file mode 100644 index 000000000..d7ba58fb3 --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift @@ -0,0 +1,86 @@ +/* + * Copyright 2023 Square Inc. + * + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ViewEnvironment + +/// `ViewEnvironmentObserving` allows an environment propagation node to observe updates to the +/// `ViewEnvironment` as it flows through the node hierarchy and have +/// the environment applied to the node. +/// +/// For example, for a `UIViewController` hierarchy observing `ViewEnvironment`: +/// ```swift +/// final class MyViewController: +/// UIViewController, ViewEnvironmentObserving +/// { +/// override func viewWillLayoutSubviews() { +/// super.viewWillLayoutSubviews() +/// +/// // You _must_ call this function in viewWillLayoutSubviews() +/// applyEnvironmentIfNeeded() +/// } +/// +/// func apply(environment: ViewEnvironment) { +/// // Apply values from the environment to your view controller (e.g. a theme) +/// } +/// +/// // If you'd like to override values in the environment you can provide them here. If you'd +/// // like to just inherit the context from above there is no need to implement this function. +/// func customize(environment: inout ViewEnvironment) { +/// environment.traits.mode = .dark +/// } +/// } +/// ``` +/// +/// - Important: `UIViewController` and `UIView` conformers _must_ call `applyEnvironmentIfNeeded()` +/// in `viewWillLayoutSubviews()` and `layoutSubviews()` respectively. +/// +public protocol ViewEnvironmentObserving: ViewEnvironmentPropagating { + /// Customizes the `ViewEnvironment` as it flows through this propagation node to provide overrides to environment + /// values. These customizations will apply to the environment on this node (e.g. `self.environment` and the + /// parameter of `apply(environment:)`) and will be propagated to all descendant nodes. + /// + /// If you'd like to just inherit the environment from above, leave this function body empty. + /// + func customize(environment: inout ViewEnvironment) + + /// Consumers should apply the `ViewEnvironment` to their node when this function is called. + /// + func apply(environment: ViewEnvironment) + + /// Consumers must call this function when environment updates should be applied. + /// + /// This will call `apply(environment:)` on the receiver if the node has been flagged for needing update. + /// + /// When working with `UIViewController` and `UIView` nodes, consumers _must_ call this function in + /// `viewWillLayoutSubviews()` for `UIViewController`s and `layoutSubviews()` for `UIView`s. + /// + func applyEnvironmentIfNeeded() + + /// Called when the environment has been set for needing update, but before it has been applied. + /// + /// This may be called frequently when compared to `apply(environment:)` which should only be called + /// when it's appropriate to apply the environment to the backing object (e.g. `viewWillLayoutSubviews`). + /// + func environmentDidChange() +} + +extension ViewEnvironmentObserving { + public func customize(environment: inout ViewEnvironment) {} + + public func apply(environment: ViewEnvironment) {} + + public func environmentDidChange() {} +} diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift new file mode 100644 index 000000000..56db9e25c --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift @@ -0,0 +1,375 @@ +/* + * Copyright 2023 Square Inc. + * + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import ViewEnvironment + +/// A node in a `ViewEnvironment` propagation tree. +/// +/// This protocol describes the base functionality of every node in the tree: +/// - Reading the environment, via `environment`. +/// - Walking up the tree, via `environmentAncestor`. +/// - Walking down the tree, via `environmentDescendants`. +/// - Notifying a node that the environment changed, via `setNeedsEnvironmentUpdate()`. +/// - Override the ancestor and descendants +/// - Add environment update observations +/// - Flag the backing object for needing to have the `ViewEnvironment` reapplied (e.g. `setNeedsLayout()`) +/// +/// This framework provides conformance of this protocol to `UIViewController` and `UIView`. +/// +public protocol ViewEnvironmentPropagating: AnyObject { + /// Calling this will flag this node for needing to update the `ViewEnvironment`. For `UIView`/`UIViewController`, + /// this will occur on the next layout pass (`setNeedsLayout` will be called on the caller's behalf). + /// + /// Any `UIViewController`/`UIView` that conforms to `ViewEnvironmentObserving` _must_ call + /// `applyEnvironmentIfNeeded()` in the subclass' `viewWillLayoutSubviews()` / `layoutSubviews()` respectively. + /// + /// - Important: Nodes providing manual conformance to this protocol should call `setNeedsEnvironmentUpdate()` on + /// all `environmentDescendants` (which is behind the `ViewEnvironmentWiring` SPI namespace). + /// + func setNeedsEnvironmentUpdate() + + /// The `ViewEnvironment` propagation ancestor. + /// + /// This describes the ancestor that the `ViewEnvironment` is inherited from. + /// + /// To override the return value of this property for `UIViewController`/`UIView` subclasses, set the + /// `environmentAncestorOverride` property. If no override is present, the return value will be `parent ?? + /// `presentingViewController`/`superview`. + /// + /// Ancestor-descendent bindings must be mutually agreed. If the value of the ancestor is `nil`, then by default, + /// other nodes configured with this node as a descendant will not notify this node of needing an environment + /// update as it changes. This allows a node to effectively act as a root node when needed (e.g. bridging from + /// other propagation systems like WorkflowUI). + /// + @_spi(ViewEnvironmentWiring) + var environmentAncestor: ViewEnvironmentPropagating? { get } + + /// The `ViewEnvironment` propagation descendants. + /// + /// This describes the descendants that will be notified when the `ViewEnvironment` changes. + /// + /// Ancestor-descendent bindings must be mutually agreed. If a descendant's `environmentAncestor` is not `self`, + /// that descendant will not be notified when the `ViewEnvironment` changes. + /// + /// To override the return value of this property for `UIViewController`/`UIView` subclasses, set the + /// `environmentDescendantsOverride` property. If no override is present, the return value will be a collection + /// of all `children` in addition to the `presentedViewController` for `UIViewController`s and `subviews` for + /// `UIView`s. + /// + @_spi(ViewEnvironmentWiring) + var environmentDescendants: [ViewEnvironmentPropagating] { get } + + /// The default ancestor for `ViewEnvironment` propagation. + /// + @_spi(ViewEnvironmentWiring) + var defaultEnvironmentAncestor: ViewEnvironmentPropagating? { get } + + /// The default descendants for `ViewEnvironment` propagation. + /// + @_spi(ViewEnvironmentWiring) + var defaultEnvironmentDescendants: [ViewEnvironmentPropagating] { get } + + /// Informs the backing object that this specific node should be flagged for another application of the + /// `ViewEnvironment`. + /// + /// For `UIViewController`/`UIView`s this typically corresponds to `setNeedsLayout()`. + /// + @_spi(ViewEnvironmentWiring) + func setNeedsApplyEnvironment() +} + +extension ViewEnvironmentPropagating { + /// The `ViewEnvironment` that is flowing through the propagation hierarchy. + /// + /// If you'd like to provide overrides for the environment as it flows through a node, you should conform to + /// `ViewEnvironmentObserving` and provide those overrides in `customize(environment:)`. E.g.: + /// ```swift + /// func customize(environment: inout ViewEnvironment) { + /// environment.traits.mode = .dark + /// } + /// ``` + /// + /// By default, this property gets the environment by recursively walking to the root of the + /// propagation path, and applying customizations on the way back down. The invalidation path may be + /// interrupted if a node has set it's `environmentAncestor` to `nil`, even if there is a node + /// which specifies this node as an `environmentDescendant`. + /// + /// If you'd like to update the return value of this variable and have those changes propagated through the + /// propagation hierarchy, conform to `ViewEnvironmentObserving` and call `setNeedsEnvironmentUpdate()` and wait + /// for the system to call `apply(context:)` when appropriate (e.g. on the next layout pass for + /// `UIViewController`/`UIView` subclasses). + /// + /// - Important: `UIViewController` and `UIView` conformers _must_ call `applyEnvironmentIfNeeded()` in + /// `viewWillLayoutSubviews()` and `layoutSubviews()` respectively. + /// + public var environment: ViewEnvironment { + var environment = environmentAncestor?.environment ?? .empty + + if let observing = self as? ViewEnvironmentObserving { + observing.customize(environment: &environment) + } + + return environment + } + + /// Consumers _must_ call this function when the environment should be re-applied, e.g. in + /// `viewWillLayoutSubviews()` for `UIViewController`s and `layoutSubviews()` for `UIView`s. + /// + /// This will call `apply(environment:)` on the receiver if the node has been flagged for needing update. + /// + public func applyEnvironmentIfNeeded() { + guard needsEnvironmentUpdate else { return } + + needsEnvironmentUpdate = false + + if let observing = self as? ViewEnvironmentObserving { + let environment = observing.environment + observing.apply(environment: environment) + } + } + + /// Notifies all appropriate descendants that the environment needs update. + /// + /// Ancestor-descendent bindings must be mutually agreed for this method to notify them. If a descendant's + /// `environmentAncestor` is not `self` it will not be notified of needing update. + /// + @_spi(ViewEnvironmentWiring) + public func setNeedsEnvironmentUpdateOnAppropriateDescendants() { + for descendant in environmentDescendants { + // If the descendant's `environmentAncestor` is not `self` it has opted out of environment updates from this + // node. The node is is likely acting as a root for propagation bridging purposes (e.g. from a Workflow + // ViewEnvironment update). + // Avoid updating the descendant if this is the case. + if descendant.environmentAncestor === self { + descendant.setNeedsEnvironmentUpdate() + } + } + } + + /// Adds a `ViewEnvironment` change observation. + /// + /// The observation will only be active for as long as the returned lifetime is retained or + /// `cancel()` is called on it. + /// + @_spi(ViewEnvironmentWiring) + public func addEnvironmentNeedsUpdateObserver( + _ onNeedsUpdate: @escaping (ViewEnvironment) -> Void + ) -> ViewEnvironmentUpdateObservationLifetime { + let object = ViewEnvironmentUpdateObservationKey() + needsUpdateObservers[object] = onNeedsUpdate + return .init { [weak self] in + self?.needsUpdateObservers[object] = nil + } + } + + /// The `ViewEnvironment` propagation ancestor. + /// + /// This describes the ancestor that the `ViewEnvironment` is inherited from. + /// + /// To override the return value of this property, set the `environmentAncestorOverride`. + /// If no override is present, the return value will be `defaultEnvironmentAncestor`. + /// + @_spi(ViewEnvironmentWiring) + public var environmentAncestor: ViewEnvironmentPropagating? { + environmentAncestorOverride?() ?? defaultEnvironmentAncestor + } + + /// The `ViewEnvironment` propagation descendants. + /// + /// This describes the descendants that will be notified when the `ViewEnvironment` changes. + /// + /// If a descendant's `environmentAncestor` is not `self`, that descendant will not be notified when the + /// `ViewEnvironment` changes. + /// + /// To override the return value of this property, set the `environmentDescendantsOverride`. + /// If no override is present, the return value will be `defaultEnvironmentDescendants`. + /// + @_spi(ViewEnvironmentWiring) + public var environmentDescendants: [ViewEnvironmentPropagating] { + environmentDescendantsOverride?() ?? defaultEnvironmentDescendants + } + + /// ## SeeAlso ## + /// - `environmentAncestorOverride` + /// + @_spi(ViewEnvironmentWiring) + public typealias EnvironmentAncestorProvider = () -> ViewEnvironmentPropagating? + + /// This property allows you to override the propagation path of the `ViewEnvironment` as it flows through the + /// node hierarchy by overriding the return value of `environmentAncestor`. + /// + /// The result of this closure should typically be the propagation node that the `ViewEnvironment` is inherited + /// from. If the value of the ancestor is nil, by default, other nodes configured with this node as a descendant + /// will not notify this node of needing an environment update as it changes. This allows a node to effectively + /// act as a root node when needed (e.g. bridging from other propagation systems like WorkflowUI). + /// + /// If this value is `nil` (the default), the resolved value for the ancestor will be `defaultEnvironmentAncestor`. + /// + /// ## Important ## + /// - You must not set overrides while overrides are already set—doing so will throw an assertion. This assertion + /// prevents accidentally clobbering an existing propagation path customization defined somewhere out of your + /// control (e.g. Modals customization). + /// + @_spi(ViewEnvironmentWiring) + public var environmentAncestorOverride: EnvironmentAncestorProvider? { + get { + objc_getAssociatedObject(self, &AssociatedKeys.ancestorOverride) as? EnvironmentAncestorProvider + } + set { + assert( + newValue == nil + || environmentAncestorOverride == nil, + "Attempting to set environment ancestor override when one is already set." + ) + objc_setAssociatedObject(self, &AssociatedKeys.ancestorOverride, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } + + /// ## SeeAlso ## + /// - `environmentDescendantsOverride` + /// + @_spi(ViewEnvironmentWiring) + public typealias EnvironmentDescendantsProvider = () -> [ViewEnvironmentPropagating] + + /// This property allows you to override the propagation path of the `ViewEnvironment` as it flows through the + /// node hierarchy by overriding the return value of `environmentDescendants`. + /// + /// The result of closure var should be the node that should be informed that there has been an update with the + /// `ViewEnvironment` updates. + /// + /// If this value is `nil` (the default), the `environmentDescendants` will be resolved to + /// `defaultEnvironmentDescendants`. + /// + /// ## Important ## + /// - You must not set overrides while overrides are already set. Doing so will throw an + /// assertion. + /// + @_spi(ViewEnvironmentWiring) + public var environmentDescendantsOverride: EnvironmentDescendantsProvider? { + get { + objc_getAssociatedObject(self, &AssociatedKeys.descendantsOverride) as? EnvironmentDescendantsProvider + } + set { + assert( + newValue == nil + || environmentDescendantsOverride == nil, + "Attempting to set environment descendants override when one is already set." + ) + objc_setAssociatedObject(self, &AssociatedKeys.descendantsOverride, newValue, .OBJC_ASSOCIATION_RETAIN) + } + } +} + +/// A closure that is called when the `ViewEnvironment` needs to be updated. +/// +public typealias ViewEnvironmentUpdateObservation = (ViewEnvironment) -> Void + +/// Describes the lifetime of a `ViewEnvironment` update observation. +/// +/// The observation will be removed when `remove()` is called or the lifetime token is +/// de-initialized. +/// +/// ## SeeAlso ## +/// - `addEnvironmentNeedsUpdateObserver(_:)` +/// +public final class ViewEnvironmentUpdateObservationLifetime { + /// Removes the observation. + /// + /// This is called in `deinit`. + /// + public func remove() { + onRemove() + } + + private let onRemove: () -> Void + + init(onRemove: @escaping () -> Void) { + self.onRemove = onRemove + } + + deinit { + remove() + } +} + +private enum ViewEnvironmentPropagatingNSObjectAssociatedKeys { + static var needsEnvironmentUpdate = NSObject() + static var needsUpdateObservers = NSObject() + static var ancestorOverride = NSObject() + static var descendantsOverride = NSObject() +} + +extension ViewEnvironmentPropagating { + private typealias AssociatedKeys = ViewEnvironmentPropagatingNSObjectAssociatedKeys + + public func setNeedsEnvironmentUpdate() { + needsEnvironmentUpdate = true + + if !needsUpdateObservers.isEmpty { + let environment = environment + + for observer in needsUpdateObservers.values { + observer(environment) + } + } + + if let observer = self as? ViewEnvironmentObserving { + observer.environmentDidChange() + + setNeedsApplyEnvironment() + } + + setNeedsEnvironmentUpdateOnAppropriateDescendants() + } + + private var needsUpdateObservers: [ViewEnvironmentUpdateObservationKey: ViewEnvironmentUpdateObservation] { + get { + objc_getAssociatedObject( + self, + &AssociatedKeys.needsUpdateObservers + ) as? [ViewEnvironmentUpdateObservationKey: ViewEnvironmentUpdateObservation] ?? [:] + } + set { + objc_setAssociatedObject( + self, + &AssociatedKeys.needsUpdateObservers, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + + var needsEnvironmentUpdate: Bool { + get { + let associatedObject = objc_getAssociatedObject( + self, + &AssociatedKeys.needsEnvironmentUpdate + ) + return (associatedObject as? Bool) ?? true + } + set { + objc_setAssociatedObject( + self, + &AssociatedKeys.needsEnvironmentUpdate, + newValue, + objc_AssociationPolicy.OBJC_ASSOCIATION_COPY + ) + } + } +} + +private class ViewEnvironmentUpdateObservationKey: NSObject {} diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift new file mode 100644 index 000000000..a44a42718 --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift @@ -0,0 +1,89 @@ +/* + * Copyright 2023 Square Inc. + * + * 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. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ViewEnvironment + +/// A `ViewEnvironment` propagation mode that can be inserted into the propagation hierarchy. +/// +/// This node can be useful when you want to re-route the propagation path and/or provide customizations to the +/// environment as it flows between two nodes. +/// +@_spi(ViewEnvironmentWiring) +public class ViewEnvironmentPropagationNode: ViewEnvironmentPropagating, ViewEnvironmentObserving { + public typealias EnvironmentAncestorProvider = () -> ViewEnvironmentPropagating? + + public typealias EnvironmentDescendantsProvider = () -> [ViewEnvironmentPropagating] + + public var environmentAncestorProvider: EnvironmentAncestorProvider { + didSet { setNeedsEnvironmentUpdate() } + } + + public var environmentDescendantsProvider: EnvironmentDescendantsProvider { + didSet { setNeedsEnvironmentUpdate() } + } + + public var customizeEnvironment: (inout ViewEnvironment) -> Void { + didSet { setNeedsEnvironmentUpdate() } + } + + public var environmentDidChangeObserver: ((ViewEnvironment) -> Void)? { + didSet { setNeedsEnvironmentUpdate() } + } + + public var applyEnvironment: (ViewEnvironment) -> Void { + didSet { setNeedsEnvironmentUpdate() } + } + + public init( + environmentAncestor: @escaping EnvironmentAncestorProvider = { nil }, + environmentDescendants: @escaping EnvironmentDescendantsProvider = { [] }, + customizeEnvironment: @escaping (inout ViewEnvironment) -> Void = { _ in }, + environmentDidChange: ((ViewEnvironment) -> Void)? = nil, + applyEnvironment: @escaping (ViewEnvironment) -> Void = { _ in } + ) { + self.environmentAncestorProvider = environmentAncestor + self.environmentDescendantsProvider = environmentDescendants + self.customizeEnvironment = customizeEnvironment + self.environmentDidChangeObserver = environmentDidChange + self.applyEnvironment = applyEnvironment + } + + public var defaultEnvironmentAncestor: ViewEnvironmentPropagating? { + environmentAncestorProvider() + } + + public var defaultEnvironmentDescendants: [ViewEnvironmentPropagating] { + environmentDescendantsProvider() + } + + public func setNeedsApplyEnvironment() { + applyEnvironmentIfNeeded() + } + + public func customize(environment: inout ViewEnvironment) { + customizeEnvironment(&environment) + } + + public func environmentDidChange() { + guard let didChange = environmentDidChangeObserver else { return } + + didChange(environment) + } + + public func apply(environment: ViewEnvironment) { + applyEnvironment(environment) + } +} diff --git a/ViewEnvironmentUI/Tests/ViewEnvironment+Test.swift b/ViewEnvironmentUI/Tests/ViewEnvironment+Test.swift new file mode 100644 index 000000000..5a84e047e --- /dev/null +++ b/ViewEnvironmentUI/Tests/ViewEnvironment+Test.swift @@ -0,0 +1,34 @@ +import ViewEnvironment + +public struct TestContext: Equatable { + static var nonDefault: Self { + .init( + number: 999, + string: "Lorem ipsum", + bool: true + ) + } + + var number: Int = 0 + var string: String = "" + var bool: Bool = false +} + +public struct TestContextKey: ViewEnvironmentKey { + public static var defaultValue: TestContext { .init() } +} + +extension ViewEnvironment { + var testContext: TestContext { + get { self[TestContextKey.self] } + set { self[TestContextKey.self] = newValue } + } +} + +extension ViewEnvironment { + static var nonDefault: Self { + var environment = Self.empty + environment.testContext = .nonDefault + return environment + } +} diff --git a/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift b/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift new file mode 100644 index 000000000..bb95b7c78 --- /dev/null +++ b/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift @@ -0,0 +1,411 @@ +#if canImport(UIKit) + +import UIKit +import ViewEnvironment +import XCTest + +@_spi(ViewEnvironmentWiring) @testable import ViewEnvironmentUI + +final class ViewEnvironmentObservingTests: XCTestCase { + // MARK: - Propagation + + func test_environment_propagation_to_child() { + let child = TestViewEnvironmentObservingViewController() + + let container = TestViewEnvironmentObservingViewController( + customizeEnvironment: { $0.testContext.number = 1 } + ) + container.addChild(child) + child.didMove(toParent: container) + + XCTAssertEqual(child.environment.testContext.number, 1) + } + + func test_environment_propagation_to_presented() { + let child = TestViewEnvironmentObservingViewController() + + let container = TestViewEnvironmentObservingViewController( + customizeEnvironment: { $0.testContext.number = 1 } + ) + + // Needed for view controller presentation to function properly + addToWindowMakingKeyAndVisible(container) + + container.present(child, animated: false, completion: {}) + + XCTAssertEqual(child.environment.testContext.number, 1) + } + + func test_environment_multiple_overrides_with_root() { + var rootEnvironment: ViewEnvironment = .empty + rootEnvironment.testContext.number = 1 + rootEnvironment.testContext.string = "Foo" + rootEnvironment.testContext.bool = true + + let child = TestViewEnvironmentObservingViewController( + customizeEnvironment: { $0.testContext.number = 2 } + ) + + let vanilla = UIViewController() + vanilla.addChild(child) + child.didMove(toParent: vanilla) + + let container = TestViewEnvironmentObservingViewController( + customizeEnvironment: { $0.testContext.string = "Bar" } + ) + container.addChild(vanilla) + vanilla.didMove(toParent: container) + + let root = TestViewEnvironmentObservingViewController( + customizeEnvironment: { $0.testContext.bool = false } + ) + root.addChild(container) + container.didMove(toParent: root) + + var expectedContext = rootEnvironment.testContext + // Mutation by root + expectedContext.bool = false + // Mutation by container + expectedContext.string = "Bar" + // Mutation by child + expectedContext.number = 2 + + XCTAssertEqual(child.environment.testContext, expectedContext) + } + + // MARK: - apply(environment:) + + func test_applyEnvironment() throws { + let expectedRootEnvironment: ViewEnvironment = .empty + + var rootAppliedEnvironments: [ViewEnvironment] = [] + let root = TestViewEnvironmentObservingViewController( + onApplyEnvironment: { rootAppliedEnvironments.append($0) } + ) + + var expectedChildEnvironment = expectedRootEnvironment + let customizedChildNumber = 42 + expectedChildEnvironment.testContext.number = customizedChildNumber + + var childAppliedEnvironments: [ViewEnvironment] = [] + let child = TestViewEnvironmentObservingViewController( + customizeEnvironment: { $0.testContext.number = customizedChildNumber }, + onApplyEnvironment: { childAppliedEnvironments.append($0) } + ) + root.addChild(child) + root.view.addSubview(child.view) + child.didMove(toParent: root) + + XCTAssertTrue(rootAppliedEnvironments.isEmpty) + XCTAssertTrue(childAppliedEnvironments.isEmpty) + + // needsEnvironmentUpdate should default to true + XCTAssertTrue(root.needsEnvironmentUpdate) + XCTAssertTrue(child.needsEnvironmentUpdate) + + // Ensure we have a window and trigger a layout pass at the root + let window = addToWindowMakingKeyAndVisible(root) + + root.view.layoutIfNeeded() + + XCTAssertEqual(rootAppliedEnvironments.count, 1) + XCTAssertEqual(childAppliedEnvironments.count, 1) + do { + let rootEnvironment = try XCTUnwrap(rootAppliedEnvironments.last) + XCTAssertEqual(rootEnvironment.testContext, expectedRootEnvironment.testContext) + + let childEnvironment = try XCTUnwrap(childAppliedEnvironments.last) + XCTAssertEqual(childEnvironment.testContext, expectedChildEnvironment.testContext) + } + + XCTAssertFalse(root.needsEnvironmentUpdate) + XCTAssertFalse(child.needsEnvironmentUpdate) + + // Flag the root for update so that both root and child receive a new application + root.setNeedsEnvironmentUpdate() + + XCTAssertTrue(root.needsEnvironmentUpdate) + XCTAssertTrue(child.needsEnvironmentUpdate) + XCTAssertEqual(rootAppliedEnvironments.count, 1) + XCTAssertEqual(childAppliedEnvironments.count, 1) + + root.view.layoutIfNeeded() + + XCTAssertEqual(rootAppliedEnvironments.count, 2) + XCTAssertEqual(childAppliedEnvironments.count, 2) + do { + let rootEnvironment = try XCTUnwrap(rootAppliedEnvironments.last) + XCTAssertEqual(rootEnvironment.testContext, expectedRootEnvironment.testContext) + + let childEnvironment = try XCTUnwrap(childAppliedEnvironments.last) + XCTAssertEqual(childEnvironment.testContext, expectedChildEnvironment.testContext) + } + + XCTAssertFalse(root.needsEnvironmentUpdate) + XCTAssertFalse(child.needsEnvironmentUpdate) + + // Flag just the child for needing update + child.setNeedsEnvironmentUpdate() + + XCTAssertFalse(root.needsEnvironmentUpdate) + XCTAssertTrue(child.needsEnvironmentUpdate) + XCTAssertEqual(rootAppliedEnvironments.count, 2) + XCTAssertEqual(childAppliedEnvironments.count, 2) + + root.view.layoutIfNeeded() + + // Only the child should have been applied + XCTAssertEqual(rootAppliedEnvironments.count, 2) + XCTAssertEqual(childAppliedEnvironments.count, 3) + XCTAssertFalse(root.needsEnvironmentUpdate) + XCTAssertFalse(child.needsEnvironmentUpdate) + do { + let childEnvironment = try XCTUnwrap(childAppliedEnvironments.last) + XCTAssertEqual(childEnvironment.testContext, expectedChildEnvironment.testContext) + } + + window.resignKey() + } + + // MARK: - environmentDidChange + + func test_environmentDidChange() { + var rootEnvironmentDidChangeCallCount = 0 + let rootNode = ViewEnvironmentPropagationNode( + environmentDidChange: { _ in + rootEnvironmentDidChangeCallCount += 1 + } + ) + XCTAssertEqual(rootEnvironmentDidChangeCallCount, 0) + + let viewController = UIViewController() + rootNode.environmentDescendantsProvider = { [viewController] } + + // Setting an environmentDescendantsProvider on ViewEnvironmentPropagationNode triggers a + // setNeedsEnvironmentUpdate() + XCTAssertEqual(rootEnvironmentDidChangeCallCount, 1) + + viewController.environmentAncestorOverride = { [weak rootNode] in + rootNode + } + + var leafEnvironmentDidChangeCallCount = 0 + let leafNode = ViewEnvironmentPropagationNode( + environmentAncestor: { [weak viewController] in + viewController + }, + environmentDidChange: { _ in + leafEnvironmentDidChangeCallCount += 1 + } + ) + viewController.environmentDescendantsOverride = { [leafNode] } + + XCTAssertEqual(rootEnvironmentDidChangeCallCount, 1) + XCTAssertEqual(leafEnvironmentDidChangeCallCount, 0) + + rootNode.setNeedsEnvironmentUpdate() + + XCTAssertEqual(rootEnvironmentDidChangeCallCount, 2) + XCTAssertEqual(leafEnvironmentDidChangeCallCount, 1) + } + + // MARK: - Overridden Flow + + func test_ancestor_customFlow() { + let expectedTestContext: TestContext = .nonDefault + + let ancestor = TestViewEnvironmentObservingViewController( + customizeEnvironment: { $0.testContext = expectedTestContext } + ) + + let viewController = UIViewController() + viewController.environmentAncestorOverride = { ancestor } + + XCTAssertEqual(viewController.environment.testContext, expectedTestContext) + } + + func test_descendant_customFlow() { + let descendant = TestViewEnvironmentObservingViewController() + + let viewController = TestViewEnvironmentObservingViewController() + viewController.environmentDescendantsOverride = { [descendant] } + + viewController.applyEnvironmentIfNeeded() + descendant.applyEnvironmentIfNeeded() + XCTAssertFalse(viewController.needsEnvironmentUpdate) + XCTAssertFalse(descendant.needsEnvironmentUpdate) + + // With no ancestor configured the descendant should not respond to needing update + viewController.setNeedsEnvironmentUpdate() + XCTAssertTrue(viewController.needsEnvironmentUpdate) + XCTAssertFalse(descendant.needsEnvironmentUpdate) + + // With an ancestor defined the VC should respond to needing update + + descendant.environmentAncestorOverride = { [weak viewController] in + viewController + } + viewController.setNeedsEnvironmentUpdate() + XCTAssertTrue(viewController.needsEnvironmentUpdate) + XCTAssertTrue(descendant.needsEnvironmentUpdate) + } + + func test_flowThroughDifferentNodeTypes() { + let rootContext = TestContext() + let expectedContext: TestContext = .nonDefault + XCTAssertNotEqual(rootContext, expectedContext) + + let root = TestViewEnvironmentObservingViewController { $0.testContext = rootContext } + let child = TestViewEnvironmentObservingViewController { $0.testContext.number = expectedContext.number } + let node = ViewEnvironmentPropagationNode( + environmentAncestor: { [weak root] in root }, + environmentDescendants: { [child] }, + customizeEnvironment: { $0.testContext.string = expectedContext.string } + ) + child.environmentAncestorOverride = { node } + root.environmentDescendantsOverride = { [node] } + let descendant = TestViewEnvironmentObservingView { $0.testContext.bool = expectedContext.bool } + child.environmentDescendantsOverride = { [descendant] } + descendant.environmentAncestorOverride = { [weak child] in child } + + XCTAssertTrue(root.needsEnvironmentUpdate) + XCTAssertTrue(child.needsEnvironmentUpdate) + XCTAssertTrue(descendant.needsEnvironmentUpdate) + + root.applyEnvironmentIfNeeded() + child.applyEnvironmentIfNeeded() + descendant.applyEnvironmentIfNeeded() + XCTAssertFalse(root.needsEnvironmentUpdate) + XCTAssertFalse(child.needsEnvironmentUpdate) + XCTAssertFalse(descendant.needsEnvironmentUpdate) + + root.setNeedsEnvironmentUpdate() + XCTAssertTrue(root.needsEnvironmentUpdate) + XCTAssertTrue(child.needsEnvironmentUpdate) + XCTAssertTrue(descendant.needsEnvironmentUpdate) + + XCTAssertEqual(descendant.environment.testContext, expectedContext) + } + + // MARK: - Observations + + func test_observation() throws { + var expectedTestContext: TestContext = .nonDefault + var observedEnvironments: [ViewEnvironment] = [] + + let viewController = UIViewController() + var observation: ViewEnvironmentUpdateObservationLifetime? = viewController + .addEnvironmentNeedsUpdateObserver { + observedEnvironments.append($0) + } + + let container = TestViewEnvironmentObservingViewController( + customizeEnvironment: { $0.testContext = expectedTestContext } + ) + container.addChild(viewController) + container.view.addSubview(viewController.view) + viewController.didMove(toParent: container) + + XCTAssertEqual(observedEnvironments.count, 0) + + container.setNeedsEnvironmentUpdate() + XCTAssertEqual(observedEnvironments.count, 1) + XCTAssertEqual(expectedTestContext, observedEnvironments.last?.testContext) + + expectedTestContext.bool = !expectedTestContext.bool + container.customizeEnvironment = { $0.testContext = expectedTestContext } + container.setNeedsEnvironmentUpdate() + XCTAssertEqual(observedEnvironments.count, 2) + XCTAssertEqual(expectedTestContext, observedEnvironments.last?.testContext) + + _ = observation // Suppress warning about variable never being read + observation = nil + + container.setNeedsEnvironmentUpdate() + XCTAssertEqual(observedEnvironments.count, 2) + } +} + +// MARK: - Helpers + +extension ViewEnvironmentObservingTests { + @discardableResult + fileprivate func addToWindowMakingKeyAndVisible(_ viewController: UIViewController) -> UIWindow { + let window = UIWindow( + frame: .init( + origin: .zero, + size: .init( + width: 600, + height: 600 + ) + ) + ) + window.rootViewController = viewController + window.makeKeyAndVisible() + return window + } + + fileprivate class TestViewEnvironmentObservingViewController: UIViewController, ViewEnvironmentObserving { + var customizeEnvironment: (inout ViewEnvironment) -> Void + var onApplyEnvironment: (ViewEnvironment) -> Void + + init( + customizeEnvironment: @escaping (inout ViewEnvironment) -> Void = { _ in }, + onApplyEnvironment: @escaping (ViewEnvironment) -> Void = { _ in } + ) { + self.customizeEnvironment = customizeEnvironment + self.onApplyEnvironment = onApplyEnvironment + + super.init(nibName: nil, bundle: nil) + } + + func customize(environment: inout ViewEnvironment) { + customizeEnvironment(&environment) + } + + func apply(environment: ViewEnvironment) { + onApplyEnvironment(environment) + } + + override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + applyEnvironmentIfNeeded() + } + + required init?(coder: NSCoder) { fatalError("") } + } + + fileprivate class TestViewEnvironmentObservingView: UIView, ViewEnvironmentObserving { + var customizeEnvironment: (inout ViewEnvironment) -> Void + var onApplyEnvironment: (ViewEnvironment) -> Void + + init( + customizeEnvironment: @escaping (inout ViewEnvironment) -> Void = { _ in }, + onApplyEnvironment: @escaping (ViewEnvironment) -> Void = { _ in } + ) { + self.customizeEnvironment = customizeEnvironment + self.onApplyEnvironment = onApplyEnvironment + + super.init(frame: .zero) + } + + func customize(environment: inout ViewEnvironment) { + customizeEnvironment(&environment) + } + + func apply(environment: ViewEnvironment) { + onApplyEnvironment(environment) + } + + override func layoutSubviews() { + applyEnvironmentIfNeeded() + + super.layoutSubviews() + } + + required init?(coder: NSCoder) { fatalError("") } + } +} + +#endif diff --git a/Workflow/Sources/StateMutationSink.swift b/Workflow/Sources/StateMutationSink.swift index 4633726e0..e2f7aa5b1 100644 --- a/Workflow/Sources/StateMutationSink.swift +++ b/Workflow/Sources/StateMutationSink.swift @@ -41,7 +41,7 @@ public struct StateMutationSink { /// Sends message to `StateMutationSink` to update `State`'s value using the provided closure. /// /// - Parameters: - /// - update: The `State`` mutation to perform. + /// - update: The `State` mutation to perform. public func send(_ update: @escaping (inout WorkflowType.State) -> Void) { sink.send( AnyWorkflowAction { state in diff --git a/WorkflowUI.podspec b/WorkflowUI.podspec index ff2297c9e..c59e354f4 100644 --- a/WorkflowUI.podspec +++ b/WorkflowUI.podspec @@ -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' } diff --git a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift index 0fecc2a40..c456cfe1d 100644 --- a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift +++ b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift @@ -18,30 +18,32 @@ import ReactiveSwift import UIKit +@_spi(ViewEnvironmentWiring) import ViewEnvironmentUI import Workflow /// Drives view controllers from a root Workflow. public final class WorkflowHostingController: UIViewController where ScreenType: Screen { + public typealias CustomizeEnvironment = (inout ViewEnvironment) -> Void + /// Emits output events from the bound workflow. public var output: Signal { return workflowHost.output } + /// An environment customization that will be applied to the environment of the root screen. + public var customizeEnvironment: CustomizeEnvironment { + didSet { setNeedsEnvironmentUpdate() } + } + private(set) var rootViewController: UIViewController private let workflowHost: WorkflowHost> private let (lifetime, token) = Lifetime.make() - public var rootViewEnvironment: ViewEnvironment { - didSet { - update(screen: workflowHost.rendering.value, environment: rootViewEnvironment) - } - } - public init( workflow: W, - rootViewEnvironment: ViewEnvironment = .empty, + customizeEnvironment: @escaping CustomizeEnvironment = { _ in }, observers: [WorkflowObserver] = [] ) where W.Rendering == ScreenType, W.Output == Output { self.workflowHost = WorkflowHost( @@ -49,15 +51,24 @@ public final class WorkflowHostingController: UIViewControll 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) @@ -68,7 +79,7 @@ public final class WorkflowHostingController: 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) } } @@ -82,15 +93,24 @@ public final class WorkflowHostingController: 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() } override public func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .white + view.backgroundColor = .clear rootViewController.view.frame = view.bounds view.addSubview(rootViewController.view) @@ -98,6 +118,11 @@ public final class WorkflowHostingController: UIViewControll updatePreferredContentSizeIfNeeded() } + public override func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + applyEnvironmentIfNeeded() + } + override public func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() rootViewController.view.frame = view.bounds @@ -150,4 +175,14 @@ public final class WorkflowHostingController: UIViewControll } } +extension WorkflowHostingController: ViewEnvironmentObserving { + public func customize(environment: inout ViewEnvironment) { + customizeEnvironment(&environment) + } + + public func environmentDidChange() { + update(screen: workflowHost.rendering.value, environment: environment) + } +} + #endif diff --git a/WorkflowUI/Sources/ModuleExports.swift b/WorkflowUI/Sources/ModuleExports.swift index 516f53ed6..716c31aa8 100644 --- a/WorkflowUI/Sources/ModuleExports.swift +++ b/WorkflowUI/Sources/ModuleExports.swift @@ -1 +1,2 @@ @_exported import ViewEnvironment +@_exported import ViewEnvironmentUI diff --git a/WorkflowUI/Sources/Screen/ScreenViewController.swift b/WorkflowUI/Sources/Screen/ScreenViewController.swift index 143c10cb8..d4f48cc7d 100644 --- a/WorkflowUI/Sources/Screen/ScreenViewController.swift +++ b/WorkflowUI/Sources/Screen/ScreenViewController.swift @@ -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. @@ -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) /// } /// } /// @@ -42,11 +44,11 @@ open class ScreenViewController: UIViewController { return ScreenType.self } - public private(set) final var environment: ViewEnvironment + private var previousEnvironment: ViewEnvironment public required init(screen: ScreenType, environment: ViewEnvironment) { self.screen = screen - self.environment = environment + self.previousEnvironment = environment super.init(nibName: nil, bundle: nil) } @@ -55,11 +57,11 @@ open class ScreenViewController: 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) } @@ -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) } ) } } diff --git a/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift b/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift index 4c88313e6..afac31096 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift @@ -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 @@ -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 @@ -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 @@ -70,6 +78,7 @@ public struct ViewControllerDescription { public init( performInitialUpdate: Bool = true, type: VC.Type = VC.self, + environment: ViewEnvironment, build: @escaping () -> VC, update: @escaping (VC) -> Void ) { @@ -77,6 +86,8 @@ public struct ViewControllerDescription { self.kind = .init(VC.self) + self.environment = environment + self.build = build self.update = { untypedViewController in @@ -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(of: viewController) } return viewController @@ -126,8 +140,43 @@ public struct ViewControllerDescription { """ ) + configureAncestor(of: viewController) + update(viewController) } + + private func configureAncestor(of viewController: UIViewController) { + guard let ancestorOverride = viewController.environmentAncestorOverride else { + // If no ancestor is currently present establish the initial ancestor override. + // + // Here we intentionally retain the node by capturing it in the `environmentAncestorOverride` closure, + // making the view controller effectively retain this node. + // The `viewController` passed into this `PropagationNode` is not retained by the node (it's a weak + // reference). + let node = PropagationNode( + viewController: viewController, + environment: environment + ) + viewController.environmentAncestorOverride = { node } + viewController.setNeedsEnvironmentUpdate() + return + } + + let currentAncestor = ancestorOverride() + // Check whether the VC's ancestor was overridden by a ViewControllerDescription. + guard let node = currentAncestor as? 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 + } + + // Update the existing node. + node.viewController = viewController + node.environment = environment + viewController.setNeedsEnvironmentUpdate() + } } extension ViewControllerDescription { @@ -168,4 +217,40 @@ extension ViewControllerDescription { } } +extension ViewControllerDescription { + fileprivate class PropagationNode: ViewEnvironmentObserving { + + // Since the viewController retains a reference to this node (via capture in the `environmentAncestorOverride` + // closure) we use a weak reference here to avoid a retain cycle, and leave retainment of the view controller + // up to the consumer of the `ViewControllerDescription` (e.g. the parent view controller). + weak var viewController: UIViewController? + + var environment: ViewEnvironment + + init( + viewController: UIViewController, + environment: ViewEnvironment + ) { + self.viewController = viewController + self.environment = environment + } + + func customize(environment: inout ViewEnvironment) { + environment = self.environment + } + + var defaultEnvironmentAncestor: ViewEnvironmentPropagating? { + nil + } + + var defaultEnvironmentDescendants: [ViewEnvironmentPropagating] { + [viewController].compactMap { $0 } + } + + func setNeedsApplyEnvironment() { + applyEnvironmentIfNeeded() + } + } +} + #endif diff --git a/WorkflowUI/Tests/DescribedViewControllerTests.swift b/WorkflowUI/Tests/DescribedViewControllerTests.swift index d550953be..84279bec9 100644 --- a/WorkflowUI/Tests/DescribedViewControllerTests.swift +++ b/WorkflowUI/Tests/DescribedViewControllerTests.swift @@ -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 } ) diff --git a/WorkflowUI/Tests/UIViewControllerExtensionTests.swift b/WorkflowUI/Tests/UIViewControllerExtensionTests.swift index 0a69a96fe..6580cce63 100644 --- a/WorkflowUI/Tests/UIViewControllerExtensionTests.swift +++ b/WorkflowUI/Tests/UIViewControllerExtensionTests.swift @@ -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 } ) @@ -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 } ) diff --git a/WorkflowUI/Tests/ViewControllerDescriptionTests.swift b/WorkflowUI/Tests/ViewControllerDescriptionTests.swift index 81238d399..5f6c18515 100644 --- a/WorkflowUI/Tests/ViewControllerDescriptionTests.swift +++ b/WorkflowUI/Tests/ViewControllerDescriptionTests.swift @@ -31,6 +31,7 @@ fileprivate class BlankViewController: UIViewController {} class ViewControllerDescriptionTests: XCTestCase { func test_build() { let description = ViewControllerDescription( + environment: .empty, build: { BlankViewController() }, update: { _ in } ) @@ -46,6 +47,7 @@ class ViewControllerDescriptionTests: XCTestCase { func test_canUpdate() { let description = ViewControllerDescription( + environment: .empty, build: { BlankViewController() }, update: { _ in } ) @@ -74,6 +76,7 @@ class ViewControllerDescriptionTests: XCTestCase { let viewController = makeAbstractViewController() let description = ViewControllerDescription( + environment: .empty, build: { viewController }, update: { $0.update() } ) @@ -86,6 +89,7 @@ class ViewControllerDescriptionTests: XCTestCase { var updateCount = 0 let description = ViewControllerDescription( performInitialUpdate: false, + environment: .empty, build: { BlankViewController() }, update: { _ in updateCount += 1 } ) @@ -103,6 +107,7 @@ class ViewControllerDescriptionTests: XCTestCase { func test_update() { var updateCount = 0 let description = ViewControllerDescription( + environment: .empty, build: { BlankViewController() }, update: { viewController in XCTAssertTrue(type(of: viewController) == BlankViewController.self) @@ -123,6 +128,56 @@ class ViewControllerDescriptionTests: XCTestCase { XCTAssertEqual(updateCount, 3) } + func test_environment_propagation() throws { + final class EnvironmentObservingViewController: UIViewController, ViewEnvironmentObserving { + let onEnvironmentDidChange: (ViewEnvironment) -> Void + init(onEnvironmentDidChange: @escaping (ViewEnvironment) -> Void) { + self.onEnvironmentDidChange = onEnvironmentDidChange + super.init(nibName: nil, bundle: nil) + } + required init?(coder: NSCoder) { fatalError() } + func environmentDidChange() { onEnvironmentDidChange(environment) } + } + + struct TestKey: ViewEnvironmentKey { + static var defaultValue: Int = 0 + } + + var changedEnvironments: [ViewEnvironment] = [] + + func makeViewController() -> EnvironmentObservingViewController { + EnvironmentObservingViewController { changedEnvironments.append($0) } + } + + func makeDescription(testValue: Int) -> ViewControllerDescription { + ViewControllerDescription( + environment: .empty.setting(key: TestKey.self, to: testValue), + build: makeViewController, + update: { _ in } + ) + } + + XCTAssertEqual(changedEnvironments.count, 0) + + let viewController = makeDescription(testValue: 1) + .buildViewController() + + XCTAssertEqual(changedEnvironments.count, 1) + do { + let environment = try XCTUnwrap(changedEnvironments.last) + XCTAssertEqual(environment[TestKey.self], 1) + } + + makeDescription(testValue: 2) + .update(viewController: viewController) + + XCTAssertEqual(changedEnvironments.count, 2) + do { + let environment = try XCTUnwrap(changedEnvironments.last) + XCTAssertEqual(environment[TestKey.self], 2) + } + } + func test_screenViewController() { // Make sure ScreenViewController.description(for:) generates a correct view controller // description diff --git a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift index 9aef957c2..88323a8ca 100644 --- a/WorkflowUI/Tests/WorkflowHostingControllerTests.swift +++ b/WorkflowUI/Tests/WorkflowHostingControllerTests.swift @@ -19,25 +19,31 @@ import XCTest import ReactiveSwift +import UIKit import Workflow import WorkflowReactiveSwift @testable import WorkflowUI fileprivate struct TestScreen: Screen { var string: String + var onEnvironmentDidChange: ((ViewEnvironment) -> Void)? func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { return TestScreenViewController.description(for: self, environment: environment) } } -fileprivate final class TestScreenViewController: ScreenViewController { +fileprivate final class TestScreenViewController: ScreenViewController, ViewEnvironmentObserving { var onScreenChange: (() -> Void)? override func screenDidChange(from previousScreen: TestScreen, previousEnvironment: ViewEnvironment) { super.screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment) onScreenChange?() } + + func environmentDidChange() { + screen.onEnvironmentDidChange?(environment) + } } class WorkflowHostingControllerTests: XCTestCase { @@ -154,6 +160,70 @@ class WorkflowHostingControllerTests: XCTestCase { disposable?.dispose() } + + func test_environment_bridging() throws { + struct WorkflowHostKey: ViewEnvironmentKey { + static var defaultValue: Int = 0 + } + struct ScreenKey: ViewEnvironmentKey { + static var defaultValue: Bool = false + } + + var changedEnvironments: [ViewEnvironment] = [] + let firstWorkflow = EnvironmentObservingWorkflow( + value: "first", + onEnvironmentDidChange: { env in + changedEnvironments.append(env) + } + ) + let container = WorkflowHostingController( + workflow: firstWorkflow + .mapRendering { + $0.adaptedEnvironment(key: ScreenKey.self, value: true) + }, + customizeEnvironment: { $0[WorkflowHostKey.self] = 1 } + ) + + // Expect a `setNeedsEnvironmentUpdate()` in the `ViewControllerDescription`'s build method and the + // `container`'s initializer. + XCTAssertEqual(changedEnvironments.count, 1) + do { + let environment = try XCTUnwrap(changedEnvironments.last) + XCTAssertEqual(environment[WorkflowHostKey.self], 1) + XCTAssertEqual(environment[ScreenKey.self], true) + } + + // Test ancestor propagation + struct AncestorKey: ViewEnvironmentKey { + static var defaultValue: String = "" + } + + let ancestorVC = EnvironmentCustomizingViewController { $0[AncestorKey.self] = "1" } + ancestorVC.addChild(container) + container.didMove(toParent: ancestorVC) + XCTAssertEqual(changedEnvironments.count, 1) + + ancestorVC.setNeedsEnvironmentUpdate() + XCTAssertEqual(changedEnvironments.count, 2) + do { + let environment = try XCTUnwrap(changedEnvironments.last) + XCTAssertEqual(environment[AncestorKey.self], "1") + XCTAssertEqual(environment[WorkflowHostKey.self], 1) + XCTAssertEqual(environment[ScreenKey.self], true) + } + + // Test an environment update. This does not implicitly trigger an environment update in this VC. + ancestorVC.customizeEnvironment = { $0[AncestorKey.self] = "2" } + // Updating customizeEnvironment on the WorkflowHostingController should trigger an environment update + container.customizeEnvironment = { $0[WorkflowHostKey.self] = 2 } + XCTAssertEqual(changedEnvironments.count, 3) + do { + let environment = try XCTUnwrap(changedEnvironments.last) + XCTAssertEqual(environment[AncestorKey.self], "2") + XCTAssertEqual(environment[WorkflowHostKey.self], 2) + XCTAssertEqual(environment[ScreenKey.self], true) + } + } } fileprivate struct SubscribingWorkflow: Workflow { @@ -219,4 +289,33 @@ fileprivate struct EchoWorkflow: Workflow { } } +fileprivate struct EnvironmentObservingWorkflow: Workflow { + var value: String + var onEnvironmentDidChange: (ViewEnvironment) -> Void + + typealias State = Void + + typealias Output = Never + + func render(state: State, context: RenderContext) -> TestScreen { + return TestScreen(string: value, onEnvironmentDidChange: onEnvironmentDidChange) + } +} + +fileprivate final class EnvironmentCustomizingViewController: UIViewController, ViewEnvironmentObserving { + + var customizeEnvironment: (inout ViewEnvironment) -> Void + + init(customizeEnvironment: @escaping (inout ViewEnvironment) -> Void) { + self.customizeEnvironment = customizeEnvironment + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError() } + + func customize(environment: inout ViewEnvironment) { + customizeEnvironment(&environment) + } +} + #endif