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..bf5720921 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"), @@ -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/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+ViewEnvironmentPropagatingObject.swift b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.swift new file mode 100644 index 000000000..53b5c9037 --- /dev/null +++ b/ViewEnvironmentUI/Sources/UIView+ViewEnvironmentPropagatingObject.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: 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..aa4bf9f45 --- /dev/null +++ b/ViewEnvironmentUI/Sources/UIViewController+ViewEnvironmentPropagatingObject.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: 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/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..db2db2183 --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagating.swift @@ -0,0 +1,126 @@ +/* + * 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 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()`. +/// +/// This framework provides conformance of this protocol to `UIViewController` and `UIView` via the +/// `ViewEnvironmentPropagatingObject` protocol. +/// +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 + /// `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 `nil`, 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 } +} + +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 + } + + /// 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 `nil` it will not be notified of needing update. + /// + @_spi(ViewEnvironmentWiring) + public func setNeedsEnvironmentUpdateOnAppropriateDescendants() { + for descendant in environmentDescendants { + // If the descendant's `environmentAncestor` 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 new file mode 100644 index 000000000..45786a7c6 --- /dev/null +++ b/ViewEnvironmentUI/Sources/ViewEnvironmentPropagatingObject.swift @@ -0,0 +1,278 @@ +/* + * 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 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 `setNeedsLayout()`. + /// + @_spi(ViewEnvironmentWiring) + func setNeedsApplyEnvironment() +} + +extension ViewEnvironmentPropagating where Self: ViewEnvironmentPropagatingObject { + /// 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) + } + } +} + +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. + /// + @_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 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 set to `nil`, 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() + } + + 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 = environment + + for observer in needsUpdateObservers.values { + observer(environment) + } + } + + if let observer = self as? ViewEnvironmentObserving { + observer.environmentDidChange() + + setNeedsApplyEnvironment() + } + + setNeedsEnvironmentUpdateOnAppropriateDescendants() + } + + 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..2cd19e2d1 --- /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: ViewEnvironmentPropagatingObject, 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..5e9e6cd59 --- /dev/null +++ b/ViewEnvironmentUI/Tests/ViewEnvironmentObservingTests.swift @@ -0,0 +1,406 @@ +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("") } + } +} 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