From 10cec9586508e4e96dc6766e42d3558766afa3ab Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 26 Sep 2022 17:12:31 -0700 Subject: [PATCH 1/6] Allow wrapping screens to guarantee a described view controller --- .../AnyContentScreen/AnyContentScreen.swift | 63 ++++++ .../DescribedViewController.swift | 103 +++++++--- .../ViewControllerDescription.swift | 4 + .../ViewTransition.swift | 179 ++++++++++++++++++ 4 files changed, 318 insertions(+), 31 deletions(-) create mode 100644 WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift create mode 100644 WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift diff --git a/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift b/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift new file mode 100644 index 000000000..79f9d0a76 --- /dev/null +++ b/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift @@ -0,0 +1,63 @@ +/* + * Copyright 2021 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 Foundation + + /// + /// + public struct AnyContentScreen: Screen { + public var transition: ViewTransition + public let content: AnyScreen + + public init( + transition: ViewTransition = .fade(), + content: () -> ScreenType + ) { + let content = content() + + if let content = content as? Self { + self = content + } else { + self.content = content.asAnyScreen() + } + + self.transition = transition + } + + // MARK: Screen + + public func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + let description = content.viewControllerDescription(environment: environment) + + return ViewControllerDescription( + /// The inner `DescribedViewController` will respect `performInitialUpdate` from + /// the nested screen – so our value should always be false. + performInitialUpdate: false, + transition: transition, + type: DescribedViewController.self, + build: { + DescribedViewController(description: description) + }, + update: { vc in + vc.update(description: description, animated: true) + } + ) + } + } + +#endif diff --git a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift index b158c6330..d8263f941 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift @@ -18,15 +18,17 @@ import UIKit + /// Displays the backing `ViewControllerDescription` for a given `Screen`. + /// public final class DescribedViewController: UIViewController { - var currentViewController: UIViewController + var content: UIViewController public init(description: ViewControllerDescription) { - self.currentViewController = description.buildViewController() + self.content = description.buildViewController() super.init(nibName: nil, bundle: nil) - addChild(currentViewController) - currentViewController.didMove(toParent: self) + addChild(content) + content.didMove(toParent: self) } public convenience init(screen: S, environment: ViewEnvironment) { @@ -38,75 +40,100 @@ fatalError("init(coder:) is unavailable") } - public func update(description: ViewControllerDescription) { - if description.canUpdate(viewController: currentViewController) { - description.update(viewController: currentViewController) + public func update(description: ViewControllerDescription, animated: Bool = false) { + if description.canUpdate(viewController: content) { + description.update(viewController: content) } else { - currentViewController.willMove(toParent: nil) - currentViewController.viewIfLoaded?.removeFromSuperview() - currentViewController.removeFromParent() + let old = content + let new = description.buildViewController() - currentViewController = description.buildViewController() - - addChild(currentViewController) + content = new if isViewLoaded { - currentViewController.view.frame = view.bounds - view.addSubview(currentViewController.view) - updatePreferredContentSizeIfNeeded() + let animated = animated && view.window != nil + + addChild(new) + old.willMove(toParent: nil) + + description.transition.transition( + from: old.view, + to: new.view, + in: view, + animated: animated, + setup: { + self.view.addSubview(new.view) + }, + completion: { + new.didMove(toParent: self) + + old.view.removeFromSuperview() + old.removeFromParent() + + self.currentViewControllerChanged() + } + ) + + } else { + addChild(new) + new.didMove(toParent: self) + + old.willMove(toParent: nil) + old.removeFromParent() } - currentViewController.didMove(toParent: self) - updatePreferredContentSizeIfNeeded() } } public func update(screen: S, environment: ViewEnvironment) { - update(description: screen.viewControllerDescription(environment: environment)) + if let screen = screen as? AnyContentScreen { + update(description: screen.content.viewControllerDescription(environment: environment)) + } else { + update(description: screen.viewControllerDescription(environment: environment)) + } } override public func viewDidLoad() { super.viewDidLoad() - currentViewController.view.frame = view.bounds - view.addSubview(currentViewController.view) + content.view.frame = view.bounds + view.addSubview(content.view) updatePreferredContentSizeIfNeeded() } override public func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - currentViewController.view.frame = view.bounds + content.view.frame = view.bounds } override public var childForStatusBarStyle: UIViewController? { - return currentViewController + return content } override public var childForStatusBarHidden: UIViewController? { - return currentViewController + return content } override public var childForHomeIndicatorAutoHidden: UIViewController? { - return currentViewController + return content } override public var childForScreenEdgesDeferringSystemGestures: UIViewController? { - return currentViewController + return content } override public var supportedInterfaceOrientations: UIInterfaceOrientationMask { - return currentViewController.supportedInterfaceOrientations + return content.supportedInterfaceOrientations } override public var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { - return currentViewController.preferredStatusBarUpdateAnimation + return content.preferredStatusBarUpdateAnimation } @available(iOS 14.0, *) override public var childViewControllerForPointerLock: UIViewController? { - return currentViewController + return content } override public func preferredContentSizeDidChange( @@ -114,17 +141,31 @@ ) { super.preferredContentSizeDidChange(forChildContentContainer: container) - guard container === currentViewController else { return } + guard container === content else { return } updatePreferredContentSizeIfNeeded() } private func updatePreferredContentSizeIfNeeded() { - let newPreferredContentSize = currentViewController.preferredContentSize + let newPreferredContentSize = content.preferredContentSize guard newPreferredContentSize != preferredContentSize else { return } preferredContentSize = newPreferredContentSize } + + private func currentViewControllerChanged() { + setNeedsFocusUpdate() + setNeedsUpdateOfHomeIndicatorAutoHidden() + + if #available(iOS 14.0, *) { + self.setNeedsUpdateOfPrefersPointerLocked() + } + + setNeedsUpdateOfScreenEdgesDeferringSystemGestures() + setNeedsStatusBarAppearanceUpdate() + + UIAccessibility.post(notification: .screenChanged, argument: nil) + } } #endif diff --git a/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift b/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift index 25a3ff734..c3e57e722 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift @@ -43,6 +43,8 @@ /// duplicate updates to your children if they are created in `init`. public var performInitialUpdate: Bool + public var transition: ViewTransition + /// Describes the `UIViewController` type that backs the `ViewControllerDescription` /// in a way that is `Equatable` and `Hashable`. When implementing view controller /// updating and diffing, you can use this type to identify if the backing view controller @@ -69,11 +71,13 @@ /// - update: Closure that updates the given view controller public init( performInitialUpdate: Bool = true, + transition: ViewTransition = .none, type: VC.Type = VC.self, build: @escaping () -> VC, update: @escaping (VC) -> Void ) { self.performInitialUpdate = performInitialUpdate + self.transition = transition self.kind = .init(VC.self) diff --git a/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift b/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift new file mode 100644 index 000000000..69bebecc2 --- /dev/null +++ b/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift @@ -0,0 +1,179 @@ +/* + * Copyright 2021 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) + + /// When the `UIViewController` backing a `DescribedViewController` changes, + /// the `ViewTransition` provided from the `ViewControllerDescription` + /// will be used to animate in the new view controller and animate out the old view controller. + /// + /// There are default transition types provided, for example `.fade` and `.scale`. If you would like + /// to create your own transition, create an instance of this type and provide the appropriate `setup` and `animate` actions. + public struct ViewTransition { + /// Creates a new transition with the provided setup and animation actions + /// + /// The `setup` action will be performed without animation – use it to set + /// up any initial view frames, opacities, etc. + /// + /// In the `animate` action, perform your animation with your desired animation APIs + /// such as `UIView.animate(withDuration: ...)`, etc. Once the animation + /// is complete, call `context.setComplete()` to mark your animation as fully complete. + public init( + setup: @escaping (Context) -> Void, + animate: @escaping (Context) -> Void + ) { + self.setup = setup + self.animate = animate + } + + func transition( + from: UIView, + to: UIView, + in container: UIView, + animated: Bool, + setup: @escaping () -> Void, + completion: @escaping () -> Void + ) { + if animated { + let context = Context(from: from, to: to, in: container, completion: completion) + + UIView.performWithoutAnimation { + self.setup(context) + setup() + } + + animate(context) + } else { + to.frame = container.bounds + + setup() + completion() + } + } + + private let setup: (Context) -> Void + private let animate: (Context) -> Void + } + + public extension ViewTransition { + /// An instant transition from the old view controller to the new view controller with no animation. + static var none: Self { + .init( + setup: { context in + context.to.frame = context.container.bounds + }, + animate: { context in + context.setCompleted() + } + ) + } + + /// Fades in the new view controller over the old view controller with the provided duration. + static func fade(with duration: TimeInterval = 0.15) -> Self { + .init( + setup: { context in + context.to.frame = context.container.bounds + context.to.alpha = 0.0 + }, + animate: { context in + UIView.animate(withDuration: duration) { + context.to.alpha = 1.0 + } completion: { _ in + context.setCompleted() + } + } + ) + } + + /// Fades and scales in the new view controller over the old view controller with the provided duration. + static func scale(with duration: TimeInterval = 0.15) -> Self { + .init( + setup: { context in + context.to.frame = context.container.bounds + context.to.alpha = 0.0 + context.to.transform = .init(scaleX: 1.25, y: 1.25) + }, + animate: { context in + UIView.animate(withDuration: duration) { + context.to.alpha = 1.0 + context.to.transform = .identity + } completion: { _ in + context.setCompleted() + } + } + ) + } + } + + public extension ViewTransition { + /// Passed to a `ViewTransition`'s `setup` and `animate` + /// actions to provide access to the `from`, `to` views, as well as allows + /// notifying the the transition that any required animations have been completed. + final class Context { + /// The view that is being transitioned away from. + /// It is at the bottom of the view hierarchy, below the `to` view. + public let from: UIView + + /// The view that is being transitioned to. + /// It is at the top of the view hierarchy, above the `from` view. + public let to: UIView + + /// The container view that is being used to coordinate the transition. + public let container: UIView + + /// Marks the transition as completed. Call this method once your + /// transition animations are completed to signal to the `DescribedViewController` + /// that it should fully complete the transition. + /// + /// You should only call this method once. Calling it multiple times will result in a fatal error. + public func setCompleted() { + guard case .running(let running) = state else { + fatalError( + """ + WorkflowUI ViewTransition Error: `setCompleted()` was called multiple times to signal \ + the end of a transition animation. Please only call setCompleted once. + """ + ) + } + + state = .complete + + running.completion() + } + + init(from: UIView, to: UIView, in container: UIView, completion: @escaping () -> Void) { + self.from = from + self.to = to + self.container = container + self.state = .running(.init(completion: completion)) + } + + private var state: State + } + } + + extension ViewTransition.Context { + private enum State { + case running(Running) + case complete + + struct Running { + var completion: () -> Void + } + } + } + +#endif From 9784951f9efd74a4a11029c413191168f5a71719 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 7 Oct 2022 15:32:25 -0700 Subject: [PATCH 2/6] Doc cleanup, pass through animated flag for update method --- .../ViewControllerDescription/DescribedViewController.swift | 6 +++--- .../Sources/ViewControllerDescription/ViewTransition.swift | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift index d8263f941..29265b134 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift @@ -85,11 +85,11 @@ } } - public func update(screen: S, environment: ViewEnvironment) { + public func update(screen: S, environment: ViewEnvironment, animated: Bool = false) { if let screen = screen as? AnyContentScreen { - update(description: screen.content.viewControllerDescription(environment: environment)) + update(description: screen.content.viewControllerDescription(environment: environment), animated: animated) } else { - update(description: screen.viewControllerDescription(environment: environment)) + update(description: screen.viewControllerDescription(environment: environment), animated: animated) } } diff --git a/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift b/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift index 69bebecc2..08f1fc3fc 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift @@ -16,9 +16,8 @@ #if canImport(UIKit) - /// When the `UIViewController` backing a `DescribedViewController` changes, - /// the `ViewTransition` provided from the `ViewControllerDescription` - /// will be used to animate in the new view controller and animate out the old view controller. + /// Used by `AnyContentScreen` and `DescribedViewController` to the backing view + /// controller when it changes. /// /// There are default transition types provided, for example `.fade` and `.scale`. If you would like /// to create your own transition, create an instance of this type and provide the appropriate `setup` and `animate` actions. From 3f5a88fae7569fd74d160dfa28076cd3131f6c70 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 7 Oct 2022 15:44:49 -0700 Subject: [PATCH 3/6] Additional documentation --- .../AnyContentScreen/AnyContentScreen.swift | 19 ++++++++++++++++++- .../DescribedViewController.swift | 12 +++++++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift b/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift index 79f9d0a76..007c3e812 100644 --- a/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift +++ b/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift @@ -18,12 +18,29 @@ import Foundation + /// Creates a wrapper screen and view controller that will animate between underlying view controllers + /// when their type changes. For example, you may use this screen to animate the transition between your + /// loading state, content state, and empty states: /// - /// + /// ``` + /// AnyContentScreen(transition: .fade) { + /// if isLoading { + /// return LoadingScreen(with: ...) + /// } else if isEmpty { + /// return ContentScreen(with ...) + /// } else { + /// return EmptyStateScreen(with: ...) + /// } + /// } + /// ``` public struct AnyContentScreen: Screen { + /// The transition to use when the underlying screen changes. Defaults to `.fade`. public var transition: ViewTransition + + /// The content screen currently displayed. public let content: AnyScreen + /// Creates a new screen with the given transition and content. public init( transition: ViewTransition = .fade(), content: () -> ScreenType diff --git a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift index 29265b134..92905bdb0 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift @@ -19,10 +19,10 @@ import UIKit /// Displays the backing `ViewControllerDescription` for a given `Screen`. - /// public final class DescribedViewController: UIViewController { var content: UIViewController + /// Creates a new view controller with the given description. public init(description: ViewControllerDescription) { self.content = description.buildViewController() super.init(nibName: nil, bundle: nil) @@ -31,6 +31,7 @@ content.didMove(toParent: self) } + /// Creates a new view controller with the screen and environment. public convenience init(screen: S, environment: ViewEnvironment) { self.init(description: screen.viewControllerDescription(environment: environment)) } @@ -40,6 +41,10 @@ fatalError("init(coder:) is unavailable") } + /// Updates the content of the view controller with the given description. + /// If the view controller can't be updated (because it's type is not the same), the old + /// content will be transitioned out, and the new one will be transitioned in + /// with the new description's `ViewTransition`. public func update(description: ViewControllerDescription, animated: Bool = false) { if description.canUpdate(viewController: content) { description.update(viewController: content) @@ -70,6 +75,7 @@ old.removeFromParent() self.currentViewControllerChanged() + self.updatePreferredContentSizeIfNeeded() } ) @@ -79,9 +85,9 @@ old.willMove(toParent: nil) old.removeFromParent() - } - updatePreferredContentSizeIfNeeded() + updatePreferredContentSizeIfNeeded() + } } } From d81d939bd4f27678976a6925536b6f0267e2a093 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 9 Oct 2022 18:06:04 -0700 Subject: [PATCH 4/6] Support transition for parent containers as well --- .../DescribedViewController.swift | 8 +-- .../UIViewController+Extensions.swift | 52 +++++++++++-------- .../ViewControllerDescription.swift | 6 ++- .../ViewTransition.swift | 10 +--- 4 files changed, 39 insertions(+), 37 deletions(-) diff --git a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift index 92905bdb0..39b5e8492 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift @@ -66,6 +66,7 @@ in: view, animated: animated, setup: { + new.view.frame = self.view.bounds self.view.addSubview(new.view) }, completion: { @@ -78,7 +79,6 @@ self.updatePreferredContentSizeIfNeeded() } ) - } else { addChild(new) new.didMove(toParent: self) @@ -92,11 +92,7 @@ } public func update(screen: S, environment: ViewEnvironment, animated: Bool = false) { - if let screen = screen as? AnyContentScreen { - update(description: screen.content.viewControllerDescription(environment: environment), animated: animated) - } else { - update(description: screen.viewControllerDescription(environment: environment), animated: animated) - } + update(description: screen.viewControllerDescription(environment: environment), animated: animated) } override public func viewDidLoad() { diff --git a/WorkflowUI/Sources/ViewControllerDescription/UIViewController+Extensions.swift b/WorkflowUI/Sources/ViewControllerDescription/UIViewController+Extensions.swift index 9ecace0b7..845234dc4 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/UIViewController+Extensions.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/UIViewController+Extensions.swift @@ -87,28 +87,38 @@ let isVisible = parent.view.window != nil - // The view should end up with the same frame. - - new.view.frame = old.view.frame - - if isVisible { - new.beginAppearanceTransition(true, animated: false) - old.beginAppearanceTransition(false, animated: false) - } - - container.insertSubview(new.view, aboveSubview: old.view) - old.view.removeFromSuperview() - - if isVisible { - new.endAppearanceTransition() - old.endAppearanceTransition() - } + description.transition.transition( + from: old.view, + to: new.view, + in: container, + animated: isVisible, + setup: { + new.view.frame = old.view.frame + + if isVisible { + new.beginAppearanceTransition(true, animated: false) + old.beginAppearanceTransition(false, animated: false) + } + + container.insertSubview(new.view, aboveSubview: old.view) + }, + completion: { + new.didMove(toParent: parent) + + old.view.removeFromSuperview() + + if isVisible { + new.endAppearanceTransition() + old.endAppearanceTransition() + } + + old.removeFromParent() + } + ) + } else { + new.didMove(toParent: parent) + old.removeFromParent() } - - // Finish the transition by signaling the vc they've fully moved in / out. - - new.didMove(toParent: parent) - old.removeFromParent() } onChange(new) diff --git a/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift b/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift index c3e57e722..08b012985 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/ViewControllerDescription.swift @@ -43,6 +43,7 @@ /// duplicate updates to your children if they are created in `init`. public var performInitialUpdate: Bool + /// A visual transition to perform when the underlying view controller type changes during an update. public var transition: ViewTransition /// Describes the `UIViewController` type that backs the `ViewControllerDescription` @@ -140,7 +141,8 @@ /// updating and diffing, you can use this type to identify if the backing view controller /// type changed. public struct KindIdentifier: Hashable { - fileprivate let viewControllerType: UIViewController.Type + /// The type of the view controller represented by the description. + let viewControllerType: UIViewController.Type private let checkViewControllerType: (UIViewController) -> Bool @@ -155,7 +157,7 @@ /// /// If your view controller type can change between updates, call this method before invoking `update(viewController:)`. public func canUpdate(viewController: UIViewController) -> Bool { - return checkViewControllerType(viewController) + checkViewControllerType(viewController) } // MARK: Hashable diff --git a/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift b/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift index 08f1fc3fc..d9149b6a0 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/ViewTransition.swift @@ -50,14 +50,12 @@ let context = Context(from: from, to: to, in: container, completion: completion) UIView.performWithoutAnimation { - self.setup(context) setup() + self.setup(context) } animate(context) } else { - to.frame = container.bounds - setup() completion() } @@ -71,9 +69,7 @@ /// An instant transition from the old view controller to the new view controller with no animation. static var none: Self { .init( - setup: { context in - context.to.frame = context.container.bounds - }, + setup: { _ in }, animate: { context in context.setCompleted() } @@ -84,7 +80,6 @@ static func fade(with duration: TimeInterval = 0.15) -> Self { .init( setup: { context in - context.to.frame = context.container.bounds context.to.alpha = 0.0 }, animate: { context in @@ -101,7 +96,6 @@ static func scale(with duration: TimeInterval = 0.15) -> Self { .init( setup: { context in - context.to.frame = context.container.bounds context.to.alpha = 0.0 context.to.transform = .init(scaleX: 1.25, y: 1.25) }, From 88434047fcb3ac5bedf24e706597a8d1ca4df984 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 24 Mar 2023 18:59:46 -0700 Subject: [PATCH 5/6] Animated by default, move test fixtures --- .../Sources/Screen/ScreenViewController.swift | 4 +- .../DescribedViewController.swift | 4 +- .../UIViewController+Extensions.swift | 12 +- .../Tests/DescribedViewControllerTests.swift | 50 ++--- .../UIViewControllerExtensionTests.swift | 188 +----------------- .../Tests/ViewControllerTextFixture.swift | 186 +++++++++++++++++ 6 files changed, 234 insertions(+), 210 deletions(-) create mode 100644 WorkflowUI/Tests/ViewControllerTextFixture.swift diff --git a/WorkflowUI/Sources/Screen/ScreenViewController.swift b/WorkflowUI/Sources/Screen/ScreenViewController.swift index adfc9ae44..1ca4efb70 100644 --- a/WorkflowUI/Sources/Screen/ScreenViewController.swift +++ b/WorkflowUI/Sources/Screen/ScreenViewController.swift @@ -74,10 +74,12 @@ public final class func description( for screen: ScreenType, environment: ViewEnvironment, - performInitialUpdate: Bool = true + performInitialUpdate: Bool = true, + transition: ViewTransition = .none ) -> ViewControllerDescription { ViewControllerDescription( performInitialUpdate: performInitialUpdate, + transition: transition, type: self, build: { self.init(screen: screen, environment: environment) }, update: { $0.update(screen: screen, environment: environment) } diff --git a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift index 39b5e8492..7047d636c 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/DescribedViewController.swift @@ -45,7 +45,7 @@ /// If the view controller can't be updated (because it's type is not the same), the old /// content will be transitioned out, and the new one will be transitioned in /// with the new description's `ViewTransition`. - public func update(description: ViewControllerDescription, animated: Bool = false) { + public func update(description: ViewControllerDescription, animated: Bool = true) { if description.canUpdate(viewController: content) { description.update(viewController: content) } else { @@ -91,7 +91,7 @@ } } - public func update(screen: S, environment: ViewEnvironment, animated: Bool = false) { + public func update(screen: S, environment: ViewEnvironment, animated: Bool = true) { update(description: screen.viewControllerDescription(environment: environment), animated: animated) } diff --git a/WorkflowUI/Sources/ViewControllerDescription/UIViewController+Extensions.swift b/WorkflowUI/Sources/ViewControllerDescription/UIViewController+Extensions.swift index 845234dc4..4e44986f2 100644 --- a/WorkflowUI/Sources/ViewControllerDescription/UIViewController+Extensions.swift +++ b/WorkflowUI/Sources/ViewControllerDescription/UIViewController+Extensions.swift @@ -36,6 +36,7 @@ child: ReferenceWritableKeyPath, with screen: ScreenType, in environment: ViewEnvironment, + animated: Bool = true, onChange: (VC) -> Void = { _ in } ) { let description = screen.viewControllerDescription(environment: environment) @@ -87,24 +88,24 @@ let isVisible = parent.view.window != nil + let animated = animated && isVisible + description.transition.transition( from: old.view, to: new.view, in: container, - animated: isVisible, + animated: animated, setup: { new.view.frame = old.view.frame if isVisible { - new.beginAppearanceTransition(true, animated: false) - old.beginAppearanceTransition(false, animated: false) + new.beginAppearanceTransition(true, animated: animated) + old.beginAppearanceTransition(false, animated: animated) } container.insertSubview(new.view, aboveSubview: old.view) }, completion: { - new.didMove(toParent: parent) - old.view.removeFromSuperview() if isVisible { @@ -112,6 +113,7 @@ old.endAppearanceTransition() } + new.didMove(toParent: parent) old.removeFromParent() } ) diff --git a/WorkflowUI/Tests/DescribedViewControllerTests.swift b/WorkflowUI/Tests/DescribedViewControllerTests.swift index 97ee859cd..eddd5bf66 100644 --- a/WorkflowUI/Tests/DescribedViewControllerTests.swift +++ b/WorkflowUI/Tests/DescribedViewControllerTests.swift @@ -34,9 +34,9 @@ // Then guard - let currentViewController = describedViewController.currentViewController as? CounterViewController + let currentViewController = describedViewController.content as? CounterViewController else { - XCTFail("Expected a \(String(reflecting: CounterViewController.self)), but got: \(describedViewController.currentViewController)") + XCTFail("Expected a \(String(reflecting: CounterViewController.self)), but got: \(describedViewController.content)") return } @@ -55,8 +55,8 @@ _ = describedViewController.view // Then - XCTAssertEqual(describedViewController.currentViewController.parent, describedViewController) - XCTAssertNotNil(describedViewController.currentViewController.viewIfLoaded?.superview) + XCTAssertEqual(describedViewController.content.parent, describedViewController) + XCTAssertNotNil(describedViewController.content.viewIfLoaded?.superview) } func test_update_toCompatibleDescription_beforeViewLoads() { @@ -65,17 +65,17 @@ let screenB = TestScreen.counter(1) let describedViewController = DescribedViewController(screen: screenA, environment: .empty) - let initialChildViewController = describedViewController.currentViewController + let initialChildViewController = describedViewController.content // When describedViewController.update(screen: screenB, environment: .empty) // Then - XCTAssertEqual(initialChildViewController, describedViewController.currentViewController) - XCTAssertEqual((describedViewController.currentViewController as? CounterViewController)?.count, 1) + XCTAssertEqual(initialChildViewController, describedViewController.content) + XCTAssertEqual((describedViewController.content as? CounterViewController)?.count, 1) XCTAssertFalse(describedViewController.isViewLoaded) - XCTAssertFalse(describedViewController.currentViewController.isViewLoaded) - XCTAssertEqual(describedViewController.currentViewController.parent, describedViewController) + XCTAssertFalse(describedViewController.content.isViewLoaded) + XCTAssertEqual(describedViewController.content.parent, describedViewController) } func test_update_toCompatibleDescription_afterViewLoads() { @@ -84,15 +84,15 @@ let screenB = TestScreen.counter(1) let describedViewController = DescribedViewController(screen: screenA, environment: .empty) - let initialChildViewController = describedViewController.currentViewController + let initialChildViewController = describedViewController.content // When _ = describedViewController.view describedViewController.update(screen: screenB, environment: .empty) // Then - XCTAssertEqual(initialChildViewController, describedViewController.currentViewController) - XCTAssertEqual((describedViewController.currentViewController as? CounterViewController)?.count, 1) + XCTAssertEqual(initialChildViewController, describedViewController.content) + XCTAssertEqual((describedViewController.content as? CounterViewController)?.count, 1) } func test_update_toIncompatibleDescription_beforeViewLoads() { @@ -101,18 +101,18 @@ let screenB = TestScreen.message("Test") let describedViewController = DescribedViewController(screen: screenA, environment: .empty) - let initialChildViewController = describedViewController.currentViewController + let initialChildViewController = describedViewController.content // When describedViewController.update(screen: screenB, environment: .empty) // Then - XCTAssertNotEqual(initialChildViewController, describedViewController.currentViewController) + XCTAssertNotEqual(initialChildViewController, describedViewController.content) XCTAssertNil(initialChildViewController.parent) - XCTAssertEqual((describedViewController.currentViewController as? MessageViewController)?.message, "Test") + XCTAssertEqual((describedViewController.content as? MessageViewController)?.message, "Test") XCTAssertFalse(describedViewController.isViewLoaded) - XCTAssertFalse(describedViewController.currentViewController.isViewLoaded) - XCTAssertEqual(describedViewController.currentViewController.parent, describedViewController) + XCTAssertFalse(describedViewController.content.isViewLoaded) + XCTAssertEqual(describedViewController.content.parent, describedViewController) } func test_update_toIncompatibleDescription_afterViewLoads() { @@ -121,19 +121,19 @@ let screenB = TestScreen.message("Test") let describedViewController = DescribedViewController(screen: screenA, environment: .empty) - let initialChildViewController = describedViewController.currentViewController + let initialChildViewController = describedViewController.content // When _ = describedViewController.view describedViewController.update(screen: screenB, environment: .empty) // Then - XCTAssertNotEqual(initialChildViewController, describedViewController.currentViewController) - XCTAssertEqual((describedViewController.currentViewController as? MessageViewController)?.message, "Test") + XCTAssertNotEqual(initialChildViewController, describedViewController.content) + XCTAssertEqual((describedViewController.content as? MessageViewController)?.message, "Test") XCTAssertNil(initialChildViewController.parent) - XCTAssertEqual(describedViewController.currentViewController.parent, describedViewController) + XCTAssertEqual(describedViewController.content.parent, describedViewController) XCTAssertNil(initialChildViewController.viewIfLoaded?.superview) - XCTAssertNotNil(describedViewController.currentViewController.viewIfLoaded?.superview) + XCTAssertNotNil(describedViewController.content.viewIfLoaded?.superview) } func test_childViewControllerFor() { @@ -141,7 +141,7 @@ let screen = TestScreen.counter(0) let describedViewController = DescribedViewController(screen: screen, environment: .empty) - let currentViewController = describedViewController.currentViewController + let currentViewController = describedViewController.content // When, Then XCTAssertEqual(describedViewController.childForStatusBarStyle, currentViewController) @@ -157,10 +157,10 @@ let screenB = TestScreen.message("Test") let describedViewController = DescribedViewController(screen: screenA, environment: .empty) - let initialChildViewController = describedViewController.currentViewController + let initialChildViewController = describedViewController.content describedViewController.update(screen: screenB, environment: .empty) - let currentViewController = describedViewController.currentViewController + let currentViewController = describedViewController.content // When, Then XCTAssertNotEqual(initialChildViewController, currentViewController) diff --git a/WorkflowUI/Tests/UIViewControllerExtensionTests.swift b/WorkflowUI/Tests/UIViewControllerExtensionTests.swift index f2b2760cc..c3adf8c14 100644 --- a/WorkflowUI/Tests/UIViewControllerExtensionTests.swift +++ b/WorkflowUI/Tests/UIViewControllerExtensionTests.swift @@ -20,9 +20,12 @@ import XCTest @testable import WorkflowUI + typealias Screen1 = ViewControllerTestFixture.Screen1 + typealias Screen2 = ViewControllerTestFixture.Screen2 + class UIViewControllerExtensionTests: XCTestCase { func test_update_viewNotLoaded() { - let fixture = TestFixture(loadView: false, screen: Screen1(), environment: .empty) + let fixture = ViewControllerTestFixture(loadView: false, screen: Screen1(), environment: .empty) // Update to the same screen type should do nothing. @@ -48,7 +51,7 @@ } func test_update_viewLoaded() { - let fixture = TestFixture(screen: Screen1(), environment: .empty) + let fixture = ViewControllerTestFixture(screen: Screen1(), environment: .empty) fixture.root.loadViewIfNeeded() // Update to the same screen type should do nothing. @@ -69,7 +72,7 @@ } func test_update_hostedView() { - let fixture = TestFixture(screen: Screen1(), environment: .empty) + let fixture = ViewControllerTestFixture(screen: Screen1(), environment: .empty) show(vc: fixture.root) { root in @@ -83,14 +86,15 @@ // Update to a new screen type should swap out the screen and send the correct events. root.update(with: Screen2(recordEvent: fixture.recordEvent), environment: .empty) + XCTAssertEqual(fixture.events, [ .child_willMoveTo(identifier: "2", parent: fixture.root), .child_willMoveTo(identifier: "1", parent: nil), .child_view_loadView(identifier: "2"), - .child_viewWillAppear(identifier: "2", animated: false), - .child_viewWillDisappear(identifier: "1", animated: false), - .child_viewDidAppear(identifier: "2", animated: false), - .child_viewDidDisappear(identifier: "1", animated: false), + .child_viewWillAppear(identifier: "2", animated: true), + .child_viewWillDisappear(identifier: "1", animated: true), + .child_viewDidAppear(identifier: "2", animated: true), + .child_viewDidDisappear(identifier: "1", animated: true), .child_didMoveTo(identifier: "2", parent: fixture.root), .child_didMoveTo(identifier: "1", parent: nil), ]) @@ -98,174 +102,4 @@ } } - fileprivate enum TestingEvent: Equatable { - // View Controller Events - - case child_viewWillAppear(identifier: String, animated: Bool) - case child_viewWillDisappear(identifier: String, animated: Bool) - case child_viewDidAppear(identifier: String, animated: Bool) - case child_viewDidDisappear(identifier: String, animated: Bool) - - case child_willMoveTo(identifier: String, parent: UIViewController?) - case child_didMoveTo(identifier: String, parent: UIViewController?) - - // View Events - - case child_view_loadView(identifier: String) - } - - private final class RootVC: UIViewController { - var content: VCBase - - init(screen: Screen, environment: ViewEnvironment) { - self.content = screen - .viewControllerDescription(environment: environment) - .buildViewController() as! VCBase - - super.init(nibName: nil, bundle: nil) - - addChild(content) - content.didMove(toParent: self) - } - - required init?(coder: NSCoder) { fatalError() } - - override func loadView() { - super.loadView() - - content.view.frame = view.bounds - - view.addSubview(content.view) - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - - content.view.frame = view.bounds - } - - func update(with screen: Screen, environment: ViewEnvironment) { - update(child: \.content, with: screen.asAnyScreen(), in: environment) - } - } - - private struct Screen1: Screen { - var recordEvent: (TestingEvent) -> Void = { _ in } - - func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { - ViewControllerDescription( - type: VC1.self, - build: { VC1(identifier: "1", recordEvent: recordEvent) }, - update: { $0.recordEvent = recordEvent } - ) - } - } - - private struct Screen2: Screen { - var recordEvent: (TestingEvent) -> Void = { _ in } - - func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { - ViewControllerDescription( - type: VC2.self, - build: { VC2(identifier: "2", recordEvent: recordEvent) }, - update: { $0.recordEvent = recordEvent } - ) - } - } - - private final class TestFixture { - public let root: RootVC - - private(set) var events: [TestingEvent] = [] - - public func clearAllEvents() { - events.removeAll() - } - - public init( - loadView: Bool = true, - screen: Screen, - environment: ViewEnvironment - ) { - self.root = .init( - screen: screen, - environment: environment - ) - - root.content.recordEvent = recordEvent - - if loadView { - root.loadViewIfNeeded() - } - - clearAllEvents() - } - - var recordEvent: (TestingEvent) -> Void { - { [weak self] in self?.events.append($0) } - } - } - - private final class VC1: VCBase {} - private final class VC2: VCBase {} - - private class VCBase: UIViewController { - let identifier: String - - var recordEvent: (TestingEvent) -> Void = { _ in } - - public init( - identifier: String, - recordEvent: @escaping (TestingEvent) -> Void - ) { - self.identifier = identifier - self.recordEvent = recordEvent - - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - override public func loadView() { - super.loadView() - - recordEvent(.child_view_loadView(identifier: identifier)) - } - - override public func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - recordEvent(.child_viewWillAppear(identifier: identifier, animated: animated)) - - /// Ensure that as we're appearing, our frame is the correct final size. - - XCTAssertEqual(view.bounds.size, parent?.view.bounds.size) - } - - override public func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - recordEvent(.child_viewDidAppear(identifier: identifier, animated: animated)) - } - - override public func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - recordEvent(.child_viewWillDisappear(identifier: identifier, animated: animated)) - } - - override public func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - recordEvent(.child_viewDidDisappear(identifier: identifier, animated: animated)) - } - - override public func willMove(toParent parent: UIViewController?) { - super.willMove(toParent: parent) - recordEvent(.child_willMoveTo(identifier: identifier, parent: parent)) - } - - override public func didMove(toParent parent: UIViewController?) { - super.didMove(toParent: parent) - recordEvent(.child_didMoveTo(identifier: identifier, parent: parent)) - } - } - #endif diff --git a/WorkflowUI/Tests/ViewControllerTextFixture.swift b/WorkflowUI/Tests/ViewControllerTextFixture.swift new file mode 100644 index 000000000..b5a5efece --- /dev/null +++ b/WorkflowUI/Tests/ViewControllerTextFixture.swift @@ -0,0 +1,186 @@ +// +// ViewControllerTextFixture.swift +// WorkflowUI-Unit-Tests +// +// Created by Kyle Van Essen on 10/18/22. +// + +#if canImport(UIKit) + + import UIKit + import WorkflowUI + import XCTest + + final class ViewControllerTestFixture { + let root: RootVC + + private(set) var events: [Event] = [] + + func clearAllEvents() { + events.removeAll() + } + + init( + loadView: Bool = true, + screen: Screen, + environment: ViewEnvironment + ) { + self.root = .init( + screen: screen, + environment: environment + ) + + root.content.recordEvent = recordEvent + + if loadView { + root.loadViewIfNeeded() + } + + clearAllEvents() + } + + var recordEvent: (Event) -> Void { + { [weak self] in self?.events.append($0) } + } + } + + extension ViewControllerTestFixture { + enum Event: Equatable { + // View Controller Events + + case child_viewWillAppear(identifier: String, animated: Bool) + case child_viewWillDisappear(identifier: String, animated: Bool) + case child_viewDidAppear(identifier: String, animated: Bool) + case child_viewDidDisappear(identifier: String, animated: Bool) + + case child_willMoveTo(identifier: String, parent: UIViewController?) + case child_didMoveTo(identifier: String, parent: UIViewController?) + + // View Events + + case child_view_loadView(identifier: String) + } + + final class RootVC: UIViewController { + var content: VCBase + + init(screen: Screen, environment: ViewEnvironment) { + self.content = screen + .viewControllerDescription(environment: environment) + .buildViewController() as! VCBase + + super.init(nibName: nil, bundle: nil) + + addChild(content) + content.didMove(toParent: self) + } + + required init?(coder: NSCoder) { fatalError() } + + override func loadView() { + super.loadView() + + content.view.frame = view.bounds + + view.addSubview(content.view) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + content.view.frame = view.bounds + } + + func update(with screen: Screen, environment: ViewEnvironment) { + update(child: \.content, with: screen.asAnyScreen(), in: environment) + } + } + + struct Screen1: Screen { + var recordEvent: (Event) -> Void = { _ in } + + func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + ViewControllerDescription( + type: VC1.self, + build: { VC1(identifier: "1", recordEvent: recordEvent) }, + update: { $0.recordEvent = recordEvent } + ) + } + } + + struct Screen2: Screen { + var recordEvent: (Event) -> Void = { _ in } + + func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + ViewControllerDescription( + type: VC2.self, + build: { VC2(identifier: "2", recordEvent: recordEvent) }, + update: { $0.recordEvent = recordEvent } + ) + } + } + + final class VC1: VCBase {} + final class VC2: VCBase {} + + class VCBase: UIViewController { + let identifier: String + + var recordEvent: (Event) -> Void = { _ in } + + public init( + identifier: String, + recordEvent: @escaping (Event) -> Void + ) { + self.identifier = identifier + self.recordEvent = recordEvent + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + override public func loadView() { + super.loadView() + + recordEvent(.child_view_loadView(identifier: identifier)) + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + recordEvent(.child_viewWillAppear(identifier: identifier, animated: animated)) + + /// Ensure that as we're appearing, our frame is the correct final size. + + XCTAssertEqual(view.bounds.size, parent?.view.bounds.size) + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + recordEvent(.child_viewDidAppear(identifier: identifier, animated: animated)) + } + + override public func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + recordEvent(.child_viewWillDisappear(identifier: identifier, animated: animated)) + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + recordEvent(.child_viewDidDisappear(identifier: identifier, animated: animated)) + } + + override public func willMove(toParent parent: UIViewController?) { + super.willMove(toParent: parent) + recordEvent(.child_willMoveTo(identifier: identifier, parent: parent)) + } + + override public func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + recordEvent(.child_didMoveTo(identifier: identifier, parent: parent)) + } + } + } + +#endif From 0bf400419eeb83ea684e141474609bcf906769d3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 13 Oct 2023 17:23:21 -0700 Subject: [PATCH 6/6] Add back generic constraint --- .../AnyContentScreen/AnyContentScreen.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift b/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift index 007c3e812..ca622534c 100644 --- a/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift +++ b/WorkflowUI/Sources/Screen/AnyContentScreen/AnyContentScreen.swift @@ -27,30 +27,31 @@ /// if isLoading { /// return LoadingScreen(with: ...) /// } else if isEmpty { - /// return ContentScreen(with ...) - /// } else { /// return EmptyStateScreen(with: ...) + /// } else { + /// return ContentScreen(with: ...) /// } /// } /// ``` - public struct AnyContentScreen: Screen { + public struct AnyContentScreen: Screen { /// The transition to use when the underlying screen changes. Defaults to `.fade`. public var transition: ViewTransition /// The content screen currently displayed. - public let content: AnyScreen + public let content: Content /// Creates a new screen with the given transition and content. - public init( + public init( transition: ViewTransition = .fade(), - content: () -> ScreenType + content: () -> Content ) { let content = content() if let content = content as? Self { self = content + self.transition = transition } else { - self.content = content.asAnyScreen() + self.content = content } self.transition = transition