From 3391f7a43c3ed926cfaab8ee233591de9339ce81 Mon Sep 17 00:00:00 2001 From: Westin Newell Date: Thu, 30 Mar 2023 10:08:02 -0700 Subject: [PATCH 1/8] Add ViewEnvironmentUI to repo --- Development.podspec | 1 + RELEASING.md | 3 +- ViewEnvironmentUI.podspec | 37 ++ ViewEnvironmentUI/README.md | 5 + ...iew+ViewEnvironmentPropagatingObject.swift | 35 ++ ...ler+ViewEnvironmentPropagatingObject.swift | 43 +++ .../Sources/ViewEnvironmentCustomizing.swift | 30 ++ .../Sources/ViewEnvironmentObserving.swift | 68 ++++ .../Sources/ViewEnvironmentPropagating.swift | 133 +++++++ .../ViewEnvironmentPropagatingObject.swift | 266 +++++++++++++ .../ViewEnvironmentPropagationNode.swift | 63 ++++ .../Tests/ViewEnvironment+Test.swift | 34 ++ .../Tests/ViewEnvironmentObservingTests.swift | 354 ++++++++++++++++++ WorkflowUI.podspec | 1 + 14 files changed, 1072 insertions(+), 1 deletion(-) create mode 100644 ViewEnvironmentUI.podspec create mode 100644 ViewEnvironmentUI/README.md create mode 100644 ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift create mode 100644 ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift create mode 100644 ViewEnvironmentUI/Sources/ViewEnvironmentCustomizing.swift create mode 100644 ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift create mode 100644 ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift create mode 100644 ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift create mode 100644 ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift create mode 100644 ViewEnvironmentUI/Tests/ViewEnvironment+Test.swift create mode 100644 ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift diff --git a/Development.podspec b/Development.podspec index f76436835..fa8ce4a71 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' 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/ViewEnvironmentUI.podspec b/ViewEnvironmentUI.podspec new file mode 100644 index 000000000..b809318a8 --- /dev/null +++ b/ViewEnvironmentUI.podspec @@ -0,0 +1,37 @@ +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' + test_spec.dependency 'WorkflowReactiveSwift', "#{s.version}" + + # 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..ad67c28b8 --- /dev/null +++ b/ViewEnvironmentUI/README.md @@ -0,0 +1,5 @@ +# ViewEnvironmentUI + +`ViewEnvironmentUI` provides a means to propagate a `ViewEnvironment` through the view controller hierarchy. + +> **_Note:_** This is currently considered an implementation detail of `MarketUI` and is intended to bridge `MarketContext` propagation between `MarketUI` and the Modals framework. Use the wrapper types declared in `MarketUI` to access the propagation of Market features. diff --git a/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift new file mode 100644 index 000000000..75c20ab00 --- /dev/null +++ b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2022 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: ViewEnvironmentPropagatingObject { + @_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+ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift new file mode 100644 index 000000000..f03db0fef --- /dev/null +++ b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift @@ -0,0 +1,43 @@ +/* + * Copyright 2022 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: ViewEnvironmentPropagatingObject { + @_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/ViewEnvironmentCustomizing.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentCustomizing.swift new file mode 100644 index 000000000..d280a1a48 --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentCustomizing.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2022 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 + +public protocol ViewEnvironmentCustomizing: ViewEnvironmentPropagating { + /// Customizes the `ViewEnvironment` as it flows through this propagation node to provide overrides to environment + /// values. These changes will be propagated to all descendant nodes. + /// + /// If you'd like to just inherit the environment from above, leave this function body empty. + /// + /// - Important: `UIViewController` and `UIView` conformers _must_ call + /// ``ViewEnvironmentObserving/applyEnvironmentIfNeeded()-8gr5k``in `viewWillLayoutSubviews()` and + /// `layoutSubviews()` respectively. + /// + func customize(environment: inout ViewEnvironment) +} diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift new file mode 100644 index 000000000..ef15b9b0a --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift @@ -0,0 +1,68 @@ +/* + * Copyright 2022 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()-3bamq`` +/// in `viewWillLayoutSubviews()` and `layoutSubviews()` respectively. +/// +/// - Tag: ViewEnvironmentObserving +/// +public protocol ViewEnvironmentObserving: ViewEnvironmentCustomizing { + /// Consumers should apply the `ViewEnvironment` to their node when this function is called. + /// + /// - Important: `UIViewController` and `UIView` conformers _must_ call ``applyEnvironmentIfNeeded()-3bamq`` + /// in `viewWillLayoutSubviews()` and `layoutSubviews()` respectively. + /// + func apply(environment: ViewEnvironment) + + /// Consumers _must_ call this function when the envirnoment 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. + /// + /// - Tag: ViewEnvironmentObserving.applyEnvironmentIfNeeded + /// + func applyEnvironmentIfNeeded() +} diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift new file mode 100644 index 000000000..4e4597574 --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift @@ -0,0 +1,133 @@ +/* + * Copyright 2022 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 + +public protocol ViewEnvironmentPropagating { + /// 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 + /// ``ViewEnvironmentObserving/applyEnvironmentIfNeeded()-8gr5k`` 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). + /// + /// - Tag: ViewEnvironmentObserving.setNeedsEnvironmentUpdate + /// + 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 + /// ``ViewEnvironmentPropagatingObject/environmentAncestorOverride`` property. If no override is present, the + /// return value will be `parent ?? presentingViewController`/`superview`. + /// + @_spi(ViewEnvironmentWiring) + var environmentAncestor: ViewEnvironmentPropagating? { get } + + /// The [`ViewEnvironment` propagation](x-source-tag://ViewEnvironmentObserving) + /// descendants. + /// + /// This describes the descendants that will be notified when the `ViewEnvironment` changes. + /// + /// To override the return value of this property for `UIViewController`/`UIView` subclasses, set the + /// ``ViewEnvironmentPropagatingObject/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 `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. You may override this + /// property instead if you want to completely interrupt the propagation flow and replace the + /// environment. You can get the default value that would normally be propagated by calling + /// `_defaultViewEnvironment`. + /// + /// 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 + /// ``ViewEnvironmentObserving/applyEnvironmentIfNeeded()-8gr5k`` in `viewWillLayoutSubviews()` and + /// `layoutSubviews()` respectively. + /// + @_spi(ViewEnvironmentWiring) + var viewEnvironment: ViewEnvironment { get } +} + +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. You may override this + /// property instead if you want to completely interrupt the propagation flow and replace the + /// environment. You can get the default value that would normally be propagated by calling + /// `_defaultViewEnvironment`. + /// + /// 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 + /// ``ViewEnvironmentObserving/applyEnvironmentIfNeeded()-8gr5k`` in `viewWillLayoutSubviews()` and + /// `layoutSubviews()` respectively. + /// + public var viewEnvironment: ViewEnvironment { + _defaultViewEnvironment + } + + /// The default `ViewEnvironment` returned by ``viewEnvironment-1p2gj``. + /// + /// The environment is constructed by recursively walking to the root of the propagation path + /// and then applying all customizations on the way back down. + /// + /// You should only need to access this value if you are overriding ``viewEnvironment-1p2gj`` + /// and want to conditionally return the default. + @_spi(ViewEnvironmentWiring) + public var _defaultViewEnvironment: ViewEnvironment { + var environment = environmentAncestor?.viewEnvironment + ?? .empty + + (self as? ViewEnvironmentCustomizing)?.customize(environment: &environment) + + return environment + } +} diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift new file mode 100644 index 000000000..6d01c0ba8 --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift @@ -0,0 +1,266 @@ +/* + * Copyright 2022 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 protocol describing a ``ViewEnvironmentPropagating`` object that can: +/// - Override the ancestor and descendants +/// - Add environment update observations +/// - Flag the backing object for needing to have the `ViewEnvironment` reapplied (e.g. `setNeedsLayout()`) +/// +/// This protocol was abstracted to share propagation logic between `UIViewController` and `UIView`'s support for +/// ``ViewEnvironmentPropagating``, but could be used for any object-based node that wants to support +/// `ViewEnvironment` propagation. +/// +public protocol ViewEnvironmentPropagatingObject: AnyObject, ViewEnvironmentPropagating { + /// 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 `setNeedsUpdate()`. + /// + @_spi(ViewEnvironmentWiring) + func setNeedsApplyEnvironment() +} + +extension ViewEnvironmentObserving where Self: ViewEnvironmentPropagatingObject { + public func applyEnvironmentIfNeeded() { + guard needsEnvironmentUpdate else { return } + + needsEnvironmentUpdate = false + + apply(environment: viewEnvironment) + } +} + +extension ViewEnvironmentPropagatingObject { + /// 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. + /// + /// - Tag: ViewEnvironmentPropagatingObject.addEnvironmentNeedsUpdateObserver + /// + @_spi(ViewEnvironmentWiring) + public func addEnvironmentNeedsUpdateObserver( + _ onNeedsUpdate: @escaping (ViewEnvironment) -> Void + ) -> ViewEnvironmentUpdateObservationLifetime { + let object = NSObject() + needsUpdateObservers[object] = onNeedsUpdate + return .init { [weak self] in + self?.needsUpdateObservers[object] = nil + } + } + + /// The [`ViewEnvironment` propagation](x-source-tag://ViewEnvironmentObserving) 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](x-source-tag://ViewEnvironmentObserving) + /// descendants. + /// + /// This describes the descendants that will 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](x-source-tag://ViewEnvironmentObserving.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 be the propagation node that the `ViewEnvironment` is inherited from. + /// + /// 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). + /// + /// - Tag: ViewEnvironmentObserving.environmentAncestorOverride + /// + @_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](x-source-tag://ViewEnvironmentPropagatingObject.addEnvironmentNeedsUpdateObserver) +/// +public final class ViewEnvironmentUpdateObservationLifetime { + /// Removes the observation. + /// + /// This is called in `deinit`. + /// + public func remove() { + onRemove() + } + + var 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 ViewEnvironmentPropagatingObject { + private typealias AssociatedKeys = ViewEnvironmentPropagatingNSObjectAssociatedKeys + + public func setNeedsEnvironmentUpdate() { + needsEnvironmentUpdate = true + + if !needsUpdateObservers.isEmpty { + let environment = viewEnvironment + + for observer in needsUpdateObservers.values { + observer(environment) + } + } + + if self is ViewEnvironmentObserving { + setNeedsApplyEnvironment() + } + + environmentDescendants.forEach { $0.setNeedsEnvironmentUpdate() } + } + + private var needsUpdateObservers: [NSObject: ViewEnvironmentUpdateObservation] { + get { + objc_getAssociatedObject( + self, + &AssociatedKeys.needsUpdateObservers + ) as? [NSObject: ViewEnvironmentUpdateObservation] ?? [:] + } + set { + objc_setAssociatedObject( + self, + &AssociatedKeys.needsUpdateObservers, + newValue, + .OBJC_ASSOCIATION_RETAIN + ) + } + } + + 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 + ) + } + } +} diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift new file mode 100644 index 000000000..0b1570023 --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift @@ -0,0 +1,63 @@ +/* + * Copyright 2022 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 struct ViewEnvironmentPropagationNode: ViewEnvironmentCustomizing { + 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 init( + environmentAncestor: @escaping EnvironmentAncestorProvider = { nil }, + environmentDescendants: @escaping EnvironmentDescendantsProvider = { [] }, + customizeEnvironment: @escaping (inout ViewEnvironment) -> Void = { _ in } + ) { + self.environmentAncestorProvider = environmentAncestor + self.environmentDescendantsProvider = environmentDescendants + self.customizeEnvironment = customizeEnvironment + } + + public var environmentAncestor: ViewEnvironmentPropagating? { environmentAncestorProvider() } + + public var environmentDescendants: [ViewEnvironmentPropagating] { environmentDescendantsProvider() } + + public func customize(environment: inout ViewEnvironment) { + customizeEnvironment(&environment) + } + + public func setNeedsEnvironmentUpdate() { + environmentDescendants.forEach { $0.setNeedsEnvironmentUpdate() } + } +} 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..453090e5d --- /dev/null +++ b/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift @@ -0,0 +1,354 @@ +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.viewEnvironment.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.viewEnvironment.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.viewEnvironment.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: - 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.viewEnvironment.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) + + 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.viewEnvironment.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("") } + } +} 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' } From 11998ee813ef2814fc425e56a50f20e16cd8c42d Mon Sep 17 00:00:00 2001 From: Westin Newell Date: Thu, 30 Mar 2023 10:35:01 -0700 Subject: [PATCH 2/8] Rename viewEnvironment to environment --- .../Sources/ViewEnvironmentPropagating.swift | 10 +++++----- .../Sources/ViewEnvironmentPropagatingObject.swift | 4 ++-- .../Tests/ViewEnvironmentObservingTests.swift | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift index 4e4597574..7d8941a49 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift @@ -81,7 +81,7 @@ public protocol ViewEnvironmentPropagating { /// `layoutSubviews()` respectively. /// @_spi(ViewEnvironmentWiring) - var viewEnvironment: ViewEnvironment { get } + var environment: ViewEnvironment { get } } extension ViewEnvironmentPropagating { @@ -110,20 +110,20 @@ extension ViewEnvironmentPropagating { /// ``ViewEnvironmentObserving/applyEnvironmentIfNeeded()-8gr5k`` in `viewWillLayoutSubviews()` and /// `layoutSubviews()` respectively. /// - public var viewEnvironment: ViewEnvironment { + public var environment: ViewEnvironment { _defaultViewEnvironment } - /// The default `ViewEnvironment` returned by ``viewEnvironment-1p2gj``. + /// The default `ViewEnvironment` returned by ``environment``. /// /// The environment is constructed by recursively walking to the root of the propagation path /// and then applying all customizations on the way back down. /// - /// You should only need to access this value if you are overriding ``viewEnvironment-1p2gj`` + /// You should only need to access this value if you are overriding ``environment`` /// and want to conditionally return the default. @_spi(ViewEnvironmentWiring) public var _defaultViewEnvironment: ViewEnvironment { - var environment = environmentAncestor?.viewEnvironment + var environment = environmentAncestor?.environment ?? .empty (self as? ViewEnvironmentCustomizing)?.customize(environment: &environment) diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift index 6d01c0ba8..3dafc207e 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift @@ -51,7 +51,7 @@ extension ViewEnvironmentObserving where Self: ViewEnvironmentPropagatingObject needsEnvironmentUpdate = false - apply(environment: viewEnvironment) + apply(environment: environment) } } @@ -215,7 +215,7 @@ extension ViewEnvironmentPropagatingObject { needsEnvironmentUpdate = true if !needsUpdateObservers.isEmpty { - let environment = viewEnvironment + let environment = self.environment for observer in needsUpdateObservers.values { observer(environment) diff --git a/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift b/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift index 453090e5d..1c64c0d32 100644 --- a/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift +++ b/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift @@ -15,7 +15,7 @@ final class ViewEnvironmentObservingTests: XCTestCase { container.addChild(child) child.didMove(toParent: container) - XCTAssertEqual(child.viewEnvironment.testContext.number, 1) + XCTAssertEqual(child.environment.testContext.number, 1) } func test_environment_propagation_to_presented() { @@ -30,7 +30,7 @@ final class ViewEnvironmentObservingTests: XCTestCase { container.present(child, animated: false, completion: {}) - XCTAssertEqual(child.viewEnvironment.testContext.number, 1) + XCTAssertEqual(child.environment.testContext.number, 1) } func test_environment_multiple_overrides_with_root() { @@ -67,7 +67,7 @@ final class ViewEnvironmentObservingTests: XCTestCase { // Mutation by child expectedContext.number = 2 - XCTAssertEqual(child.viewEnvironment.testContext, expectedContext) + XCTAssertEqual(child.environment.testContext, expectedContext) } // MARK: - apply(environment:) @@ -176,7 +176,7 @@ final class ViewEnvironmentObservingTests: XCTestCase { let viewController = UIViewController() viewController.environmentAncestorOverride = { ancestor } - XCTAssertEqual(viewController.viewEnvironment.testContext, expectedTestContext) + XCTAssertEqual(viewController.environment.testContext, expectedTestContext) } func test_descendant_customFlow() { @@ -229,7 +229,7 @@ final class ViewEnvironmentObservingTests: XCTestCase { XCTAssertTrue(child.needsEnvironmentUpdate) XCTAssertTrue(descendant.needsEnvironmentUpdate) - XCTAssertEqual(descendant.viewEnvironment.testContext, expectedContext) + XCTAssertEqual(descendant.environment.testContext, expectedContext) } // MARK: - Observations From 89f8104337ca370f20348ae1fd024cc0da636ce1 Mon Sep 17 00:00:00 2001 From: Westin Newell Date: Thu, 30 Mar 2023 10:54:04 -0700 Subject: [PATCH 3/8] Only call setNeedsEnvironmentUpdate() on descendants that have a non-nil ancestor --- .../Sources/ViewEnvironmentPropagating.swift | 12 ++++++++++++ .../Sources/ViewEnvironmentPropagatingObject.swift | 2 +- .../Sources/ViewEnvironmentPropagationNode.swift | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift index 7d8941a49..c2ad30ed3 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift @@ -130,4 +130,16 @@ extension ViewEnvironmentPropagating { return environment } + + @_spi(ViewEnvironmentWiring) + public func setNeedsEnvironmentUpdateOnAppropriateDescendants() { + for descendant in environmentDescendants { + // If the descendant's ancestor is nil it has opted out of environment updates and 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. + guard descendant.environmentAncestor != nil else { continue } + + descendant.setNeedsEnvironmentUpdate() + } + } } diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift index 3dafc207e..9369086fb 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift @@ -226,7 +226,7 @@ extension ViewEnvironmentPropagatingObject { setNeedsApplyEnvironment() } - environmentDescendants.forEach { $0.setNeedsEnvironmentUpdate() } + setNeedsEnvironmentUpdateOnAppropriateDescendants() } private var needsUpdateObservers: [NSObject: ViewEnvironmentUpdateObservation] { diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift index 0b1570023..98871a583 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift @@ -58,6 +58,6 @@ public struct ViewEnvironmentPropagationNode: ViewEnvironmentCustomizing { } public func setNeedsEnvironmentUpdate() { - environmentDescendants.forEach { $0.setNeedsEnvironmentUpdate() } + setNeedsEnvironmentUpdateOnAppropriateDescendants() } } From 9dab8e59435b1e6196e67d6c18200672e4e24e6f Mon Sep 17 00:00:00 2001 From: Westin Newell Date: Thu, 30 Mar 2023 10:29:52 -0700 Subject: [PATCH 4/8] Add bridging to WorkflowHostingController --- .../Hosting/WorkflowHostingController.swift | 80 ++++++++++++++----- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift index 0fecc2a40..c834338ea 100644 --- a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift +++ b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift @@ -18,13 +18,15 @@ import ReactiveSwift import UIKit +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 + workflowHost.output } private(set) var rootViewController: UIViewController @@ -33,28 +35,34 @@ public final class WorkflowHostingController: UIViewControll private let (lifetime, token) = Lifetime.make() - public var rootViewEnvironment: ViewEnvironment { + public var customizeEnvironment: CustomizeEnvironment { didSet { - update(screen: workflowHost.rendering.value, environment: rootViewEnvironment) + setNeedsEnvironmentUpdate() } } public init( workflow: W, - rootViewEnvironment: ViewEnvironment = .empty, - observers: [WorkflowObserver] = [] + observers: [WorkflowObserver] = [], + customizeEnvironment: @escaping CustomizeEnvironment = { _ in } ) where W.Rendering == ScreenType, W.Output == Output { self.workflowHost = WorkflowHost( workflow: workflow.asAnyWorkflow(), observers: observers ) - self.rootViewController = workflowHost + self.customizeEnvironment = customizeEnvironment + + // Customize the default environment for the first render so that we can perform updates and query view + // controller containment methods before the view has been added to the hierarchy. + var customizedEnvironment: ViewEnvironment = .empty + customizeEnvironment(&customizedEnvironment) + + rootViewController = workflowHost .rendering .value - .buildViewController(in: rootViewEnvironment) - - self.rootViewEnvironment = rootViewEnvironment + .viewControllerDescription(environment: customizedEnvironment) + .buildViewController() super.init(nibName: nil, bundle: nil) @@ -66,9 +74,7 @@ public final class WorkflowHostingController: UIViewControll .signal .take(during: lifetime) .observeValues { [weak self] screen in - guard let self = self else { return } - - self.update(screen: screen, environment: self.rootViewEnvironment) + self?.update(screen: screen) } } @@ -81,15 +87,33 @@ public final class WorkflowHostingController: UIViewControll fatalError("init(coder:) has not been implemented") } + private func update(screen: ScreenType) { + update(screen: screen, environment: environment) + } + private func update(screen: ScreenType, environment: ViewEnvironment) { + let previousRoot = rootViewController + update(child: \.rootViewController, with: screen, in: environment) + if previousRoot !== rootViewController { + setNeedsEnvironmentUpdate() + } + updatePreferredContentSizeIfNeeded() } override public func viewDidLoad() { super.viewDidLoad() + let environment = self.environment + + // Update before loading the contained view controller's view so that the environment can fully propagate + // before descendant views have loaded. + // Many screens rely on `ViewEnvironment` validations in viewDidLoad which could be using the initial + // `ViewEnvironment` without this explicit update. + update(screen: workflowHost.rendering.value, environment: environment) + view.backgroundColor = .white rootViewController.view.frame = view.bounds @@ -98,37 +122,43 @@ public final class WorkflowHostingController: UIViewControll updatePreferredContentSizeIfNeeded() } + override public func viewWillLayoutSubviews() { + super.viewWillLayoutSubviews() + + applyEnvironmentIfNeeded() + } + override public func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() rootViewController.view.frame = view.bounds } override public var childForStatusBarStyle: UIViewController? { - return rootViewController + rootViewController } override public var childForStatusBarHidden: UIViewController? { - return rootViewController + rootViewController } override public var childForHomeIndicatorAutoHidden: UIViewController? { - return rootViewController + rootViewController } override public var childForScreenEdgesDeferringSystemGestures: UIViewController? { - return rootViewController + rootViewController } override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return rootViewController.supportedInterfaceOrientations + rootViewController.supportedInterfaceOrientations } override public var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { - return rootViewController.preferredStatusBarUpdateAnimation + rootViewController.preferredStatusBarUpdateAnimation } override public var childViewControllerForPointerLock: UIViewController? { - return rootViewController + rootViewController } override public func preferredContentSizeDidChange( @@ -150,4 +180,14 @@ public final class WorkflowHostingController: UIViewControll } } +extension WorkflowHostingController: ViewEnvironmentCustomizing, ViewEnvironmentObserving { + public func apply(environment: ViewEnvironment) { + update(screen: workflowHost.rendering.value, environment: environment) + } + + public func customize(environment: inout ViewEnvironment) { + customizeEnvironment(&environment) + } +} + #endif From 7a8f3198651ec48ae76aa2b5c011edd20cf16fcb Mon Sep 17 00:00:00 2001 From: Westin Newell Date: Thu, 30 Mar 2023 10:37:34 -0700 Subject: [PATCH 5/8] Add bridging to ScreenViewController --- .../Sources/Screen/ScreenViewController.swift | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/WorkflowUI/Sources/Screen/ScreenViewController.swift b/WorkflowUI/Sources/Screen/ScreenViewController.swift index 143c10cb8..52c7e9925 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. @@ -39,31 +41,41 @@ open class ScreenViewController: UIViewController { public private(set) final var screen: ScreenType public final var screenType: Screen.Type { - return ScreenType.self + ScreenType.self } - public private(set) final var environment: ViewEnvironment + private var _environment: ViewEnvironment public required init(screen: ScreenType, environment: ViewEnvironment) { self.screen = screen - self.environment = environment + self._environment = environment super.init(nibName: nil, bundle: nil) + + let ancestor = ViewEnvironmentPropagationNode( + environmentDescendants: { [weak self] in + [self].compactMap { $0 } + }, + customizeEnvironment: { [weak self] environment in + guard let self else { return } + environment = self._environment + } + ) + + environmentAncestorOverride = { ancestor } } @available(*, unavailable) - public required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + public required init?(coder aDecoder: NSCoder) { fatalError() } public final func update(screen: ScreenType, environment: ViewEnvironment) { let previousScreen = self.screen self.screen = screen let previousEnvironment = self.environment - self.environment = environment + _environment = environment + setNeedsEnvironmentUpdate() screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment) } - /// Subclasses should override this method in order to update any relevant UI bits when the screen model changes. open func screenDidChange(from previousScreen: ScreenType, previousEnvironment: ViewEnvironment) {} } From 8c2c20700f9036385b2a83e9b71b376794b61e81 Mon Sep 17 00:00:00 2001 From: Westin Newell Date: Fri, 31 Mar 2023 13:30:10 -0700 Subject: [PATCH 6/8] Add didChange observation --- ...iew+ViewEnvironmentPropagatingObject.swift | 4 ++ ...ler+ViewEnvironmentPropagatingObject.swift | 4 ++ .../Sources/ViewEnvironmentObserving.swift | 10 ++++- .../ViewEnvironmentPropagationNode.swift | 38 ++++++++++++++++++- .../Hosting/WorkflowHostingController.swift | 8 ++-- .../Sources/Screen/ScreenViewController.swift | 2 +- 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift index 75c20ab00..9a7db8b3f 100644 --- a/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift +++ b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift @@ -29,6 +29,10 @@ extension UIView: ViewEnvironmentPropagatingObject { @_spi(ViewEnvironmentWiring) public func setNeedsApplyEnvironment() { setNeedsLayout() + + if let observing = self as? ViewEnvironmentObserving { + observing.environmentDidChange() + } } } diff --git a/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift index f03db0fef..cb61c6974 100644 --- a/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift +++ b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift @@ -37,6 +37,10 @@ extension UIViewController: ViewEnvironmentPropagatingObject { @_spi(ViewEnvironmentWiring) public func setNeedsApplyEnvironment() { viewIfLoaded?.setNeedsLayout() + + if let observing = self as? ViewEnvironmentObserving { + observing.environmentDidChange() + } } } diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift index ef15b9b0a..9b2f49ad8 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentObserving.swift @@ -57,7 +57,7 @@ public protocol ViewEnvironmentObserving: ViewEnvironmentCustomizing { /// func apply(environment: ViewEnvironment) - /// Consumers _must_ call this function when the envirnoment should be re-applied, e.g. in + /// 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. @@ -65,4 +65,12 @@ public protocol ViewEnvironmentObserving: ViewEnvironmentCustomizing { /// - Tag: ViewEnvironmentObserving.applyEnvironmentIfNeeded /// func applyEnvironmentIfNeeded() + + func environmentDidChange() +} + +extension ViewEnvironmentObserving { + public func apply(environment: ViewEnvironment) {} + + public func environmentDidChange() {} } diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift index 98871a583..1d824f434 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift @@ -22,7 +22,7 @@ import ViewEnvironment /// environment as it flows between two nodes. /// @_spi(ViewEnvironmentWiring) -public struct ViewEnvironmentPropagationNode: ViewEnvironmentCustomizing { +public class ViewEnvironmentPropagationNode: ViewEnvironmentCustomizing, ViewEnvironmentObserving { public typealias EnvironmentAncestorProvider = () -> ViewEnvironmentPropagating? public typealias EnvironmentDescendantsProvider = () -> [ViewEnvironmentPropagating] @@ -39,14 +39,28 @@ public struct ViewEnvironmentPropagationNode: ViewEnvironmentCustomizing { didSet { setNeedsEnvironmentUpdate() } } + public var environmentDidChangeObserver: ((ViewEnvironment) -> Void)? { + didSet { setNeedsEnvironmentUpdate() } + } + + public var applyEnvironment: (ViewEnvironment) -> Void { + didSet { setNeedsEnvironmentUpdate() } + } + + private var needsEnvironmentUpdate: Bool = true + public init( environmentAncestor: @escaping EnvironmentAncestorProvider = { nil }, environmentDescendants: @escaping EnvironmentDescendantsProvider = { [] }, - customizeEnvironment: @escaping (inout ViewEnvironment) -> Void = { _ in } + 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 environmentAncestor: ViewEnvironmentPropagating? { environmentAncestorProvider() } @@ -58,6 +72,26 @@ public struct ViewEnvironmentPropagationNode: ViewEnvironmentCustomizing { } public func setNeedsEnvironmentUpdate() { + needsEnvironmentUpdate = true + setNeedsEnvironmentUpdateOnAppropriateDescendants() } + + public func environmentDidChange() { + guard let didChange = environmentDidChangeObserver else { return } + + didChange(environment) + } + + public func applyEnvironmentIfNeeded() { + guard needsEnvironmentUpdate else { return } + + needsEnvironmentUpdate = false + + apply(environment: environment) + } + + public func apply(environment: ViewEnvironment) { + applyEnvironment(environment) + } } diff --git a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift index c834338ea..f9d3f353e 100644 --- a/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift +++ b/WorkflowUI/Sources/Hosting/WorkflowHostingController.swift @@ -181,13 +181,13 @@ public final class WorkflowHostingController: UIViewControll } extension WorkflowHostingController: ViewEnvironmentCustomizing, ViewEnvironmentObserving { - public func apply(environment: ViewEnvironment) { - update(screen: workflowHost.rendering.value, environment: environment) - } - public func customize(environment: inout ViewEnvironment) { customizeEnvironment(&environment) } + + public func environmentDidChange() { + update(screen: workflowHost.rendering.value, environment: environment) + } } #endif diff --git a/WorkflowUI/Sources/Screen/ScreenViewController.swift b/WorkflowUI/Sources/Screen/ScreenViewController.swift index 52c7e9925..966570a29 100644 --- a/WorkflowUI/Sources/Screen/ScreenViewController.swift +++ b/WorkflowUI/Sources/Screen/ScreenViewController.swift @@ -72,8 +72,8 @@ open class ScreenViewController: UIViewController { self.screen = screen let previousEnvironment = self.environment _environment = environment - setNeedsEnvironmentUpdate() screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment) + setNeedsEnvironmentUpdate() } open func screenDidChange(from previousScreen: ScreenType, previousEnvironment: ViewEnvironment) {} From 6cd16c1c88c78196326086dda61ca914786af9c5 Mon Sep 17 00:00:00 2001 From: Westin Newell Date: Mon, 3 Apr 2023 11:34:03 -0700 Subject: [PATCH 7/8] Move bridging to ViewControllerDescription --- .../Sources/Screen/ScreenViewController.swift | 28 ++---- .../ViewControllerDescription.swift | 88 +++++++++++++++++++ 2 files changed, 97 insertions(+), 19 deletions(-) diff --git a/WorkflowUI/Sources/Screen/ScreenViewController.swift b/WorkflowUI/Sources/Screen/ScreenViewController.swift index 966570a29..796fd03ff 100644 --- a/WorkflowUI/Sources/Screen/ScreenViewController.swift +++ b/WorkflowUI/Sources/Screen/ScreenViewController.swift @@ -44,36 +44,25 @@ open class ScreenViewController: UIViewController { ScreenType.self } - private 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) - - let ancestor = ViewEnvironmentPropagationNode( - environmentDescendants: { [weak self] in - [self].compactMap { $0 } - }, - customizeEnvironment: { [weak self] environment in - guard let self else { return } - environment = self._environment - } - ) - - environmentAncestorOverride = { ancestor } } @available(*, unavailable) public required init?(coder aDecoder: NSCoder) { fatalError() } - 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 - _environment = environment + + let previousEnvironment = self.previousEnvironment + self.previousEnvironment = environment + screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment) - setNeedsEnvironmentUpdate() } open func screenDidChange(from previousScreen: ScreenType, previousEnvironment: ViewEnvironment) {} @@ -90,9 +79,10 @@ extension ScreenViewController { ) -> ViewControllerDescription { ViewControllerDescription( performInitialUpdate: performInitialUpdate, + environment: environment, type: self, 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..993405e45 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift @@ -17,6 +17,7 @@ #if canImport(UIKit) import UIKit +@_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 @@ -52,6 +53,8 @@ public struct ViewControllerDescription { private let build: () -> UIViewController private let update: (UIViewController) -> Void + private let environment: ViewEnvironment + /// Constructs a view controller description by providing closures used to /// build and update a specific view controller type. /// @@ -69,6 +72,7 @@ public struct ViewControllerDescription { /// - update: Closure that updates the given view controller public init( performInitialUpdate: Bool = true, + environment: ViewEnvironment, type: VC.Type = VC.self, build: @escaping () -> VC, update: @escaping (VC) -> Void @@ -77,6 +81,8 @@ public struct ViewControllerDescription { self.kind = .init(VC.self) + self.environment = environment + self.build = build self.update = { untypedViewController in @@ -96,6 +102,8 @@ public struct ViewControllerDescription { if performInitialUpdate { // Perform an initial update of the built view controller update(viewController: viewController) + } else { + configureAncestor(for: viewController, with: environment) } return viewController @@ -126,8 +134,44 @@ public struct ViewControllerDescription { """ ) + configureAncestor(for: viewController, with: environment) + update(viewController) } + + private func configureAncestor(for viewController: UIViewController, with environment: ViewEnvironment) { + guard let ancestorOverride = viewController.environmentAncestorOverride else { + establishAncestorOverride(for: viewController, with: environment) + return + } + + let currentAncestor = ancestorOverride() + 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, 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 + establishAncestorOverride(for: viewController, with: environment) + } + + private func establishAncestorOverride(for viewController: UIViewController, with environment: ViewEnvironment) { + let ancestor = PropagationNode( + environmentAncestor: { nil }, + environmentDescendants: { [weak viewController] in + [viewController].compactMap { $0 } + }, + customizeEnvironment: { $0 = environment } + ) + viewController.environmentAncestorOverride = { ancestor } + + ancestor.setNeedsEnvironmentUpdate() + } } extension ViewControllerDescription { @@ -168,4 +212,48 @@ extension ViewControllerDescription { } } +extension ViewControllerDescription { + fileprivate struct PropagationNode: ViewEnvironmentCustomizing { + typealias EnvironmentAncestorProvider = () -> ViewEnvironmentPropagating? + + typealias EnvironmentDescendantsProvider = () -> [ViewEnvironmentPropagating] + + var environmentAncestorProvider: EnvironmentAncestorProvider { + didSet { setNeedsEnvironmentUpdate() } + } + + var environmentDescendantsProvider: EnvironmentDescendantsProvider { + didSet { setNeedsEnvironmentUpdate() } + } + + var customizeEnvironment: (inout ViewEnvironment) -> Void { + didSet { setNeedsEnvironmentUpdate() } + } + + private var needsEnvironmentUpdate: Bool = true + + init( + environmentAncestor: @escaping EnvironmentAncestorProvider = { nil }, + environmentDescendants: @escaping EnvironmentDescendantsProvider = { [] }, + customizeEnvironment: @escaping (inout ViewEnvironment) -> Void = { _ in } + ) { + self.environmentAncestorProvider = environmentAncestor + self.environmentDescendantsProvider = environmentDescendants + self.customizeEnvironment = customizeEnvironment + } + + var environmentAncestor: ViewEnvironmentPropagating? { environmentAncestorProvider() } + + var environmentDescendants: [ViewEnvironmentPropagating] { environmentDescendantsProvider() } + + func customize(environment: inout ViewEnvironment) { + customizeEnvironment(&environment) + } + + func setNeedsEnvironmentUpdate() { + setNeedsEnvironmentUpdateOnAppropriateDescendants() + } + } +} + #endif From 6935bf8e0897d9f6bf79ec2dc1c780e5749f474f Mon Sep 17 00:00:00 2001 From: Westin Newell Date: Mon, 10 Apr 2023 14:20:47 -0700 Subject: [PATCH 8/8] Fix environmentDidChange --- .../Sources/UIView+ViewEnvironmentPropagatingObject.swift | 4 ---- .../UIViewController+ViewEnvironmentPropagatingObject.swift | 4 ---- ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift | 4 +++- .../Sources/ViewEnvironmentPropagatingObject.swift | 4 ++++ .../Sources/ViewEnvironmentPropagationNode.swift | 2 ++ 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift index 9a7db8b3f..75c20ab00 100644 --- a/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift +++ b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift @@ -29,10 +29,6 @@ extension UIView: ViewEnvironmentPropagatingObject { @_spi(ViewEnvironmentWiring) public func setNeedsApplyEnvironment() { setNeedsLayout() - - if let observing = self as? ViewEnvironmentObserving { - observing.environmentDidChange() - } } } diff --git a/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift index cb61c6974..f03db0fef 100644 --- a/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift +++ b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.swift @@ -37,10 +37,6 @@ extension UIViewController: ViewEnvironmentPropagatingObject { @_spi(ViewEnvironmentWiring) public func setNeedsApplyEnvironment() { viewIfLoaded?.setNeedsLayout() - - if let observing = self as? ViewEnvironmentObserving { - observing.environmentDidChange() - } } } diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift index c2ad30ed3..1c278fefc 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift @@ -137,7 +137,9 @@ extension ViewEnvironmentPropagating { // If the descendant's ancestor is nil it has opted out of environment updates and 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. - guard descendant.environmentAncestor != nil else { continue } + guard descendant.environmentAncestor != nil else { + continue + } descendant.setNeedsEnvironmentUpdate() } diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift index 9369086fb..9b685d9bf 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift @@ -214,6 +214,10 @@ extension ViewEnvironmentPropagatingObject { public func setNeedsEnvironmentUpdate() { needsEnvironmentUpdate = true + if let observing = self as? ViewEnvironmentObserving { + observing.environmentDidChange() + } + if !needsUpdateObservers.isEmpty { let environment = self.environment diff --git a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift index 1d824f434..cddb4bcb9 100644 --- a/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagationNode.swift @@ -74,6 +74,8 @@ public class ViewEnvironmentPropagationNode: ViewEnvironmentCustomizing, ViewEnv public func setNeedsEnvironmentUpdate() { needsEnvironmentUpdate = true + environmentDidChange() + setNeedsEnvironmentUpdateOnAppropriateDescendants() }