Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP DNR] Allow wrapping screens to guarantee a described view controller #159

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* 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

/// 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 EmptyStateScreen(with: ...)
/// } else {
/// return ContentScreen(with: ...)
/// }
/// }
/// ```
public struct AnyContentScreen<Content: Screen>: 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: Content

/// Creates a new screen with the given transition and content.
public init(
transition: ViewTransition = .fade(),
content: () -> Content
) {
let content = content()

if let content = content as? Self {
self = content
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, this drops the transition

self.transition = transition
} else {
self.content = content
}

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
4 changes: 3 additions & 1 deletion WorkflowUI/Sources/Screen/ScreenViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,20 @@

import UIKit

/// Displays the backing `ViewControllerDescription` for a given `Screen`.
public final class DescribedViewController: UIViewController {
var currentViewController: UIViewController
var content: UIViewController

/// Creates a new view controller with the given description.
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)
}

/// Creates a new view controller with the screen and environment.
public convenience init<S: Screen>(screen: S, environment: ViewEnvironment) {
self.init(description: screen.viewControllerDescription(environment: environment))
}
Expand All @@ -38,93 +41,133 @@
fatalError("init(coder:) is unavailable")
}

public func update(description: ViewControllerDescription) {
if description.canUpdate(viewController: currentViewController) {
description.update(viewController: currentViewController)
/// 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 = true) {
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)
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: {
new.view.frame = self.view.bounds
self.view.addSubview(new.view)
},
completion: {
new.didMove(toParent: self)

old.view.removeFromSuperview()
old.removeFromParent()

self.currentViewControllerChanged()
self.updatePreferredContentSizeIfNeeded()
}
)
} else {
addChild(new)
new.didMove(toParent: self)

old.willMove(toParent: nil)
old.removeFromParent()

updatePreferredContentSizeIfNeeded()
}

currentViewController.didMove(toParent: self)

updatePreferredContentSizeIfNeeded()
}
}

public func update<S: Screen>(screen: S, environment: ViewEnvironment) {
update(description: screen.viewControllerDescription(environment: environment))
public func update<S: Screen>(screen: S, environment: ViewEnvironment, animated: Bool = true) {
update(description: screen.viewControllerDescription(environment: environment), animated: animated)
}

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(
forChildContentContainer container: UIContentContainer
) {
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() {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

  • I think this needs update for iOS 16 and 17

setNeedsFocusUpdate()
setNeedsUpdateOfHomeIndicatorAutoHidden()

if #available(iOS 14.0, *) {
self.setNeedsUpdateOfPrefersPointerLocked()
}

setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
setNeedsStatusBarAppearanceUpdate()

UIAccessibility.post(notification: .screenChanged, argument: nil)
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
child: ReferenceWritableKeyPath<Self, VC>,
with screen: ScreenType,
in environment: ViewEnvironment,
animated: Bool = true,
onChange: (VC) -> Void = { _ in }
) {
let description = screen.viewControllerDescription(environment: environment)
Expand Down Expand Up @@ -87,28 +88,39 @@

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()
}
let animated = animated && isVisible

description.transition.transition(
from: old.view,
to: new.view,
in: container,
animated: animated,
setup: {
new.view.frame = old.view.frame

if isVisible {
new.beginAppearanceTransition(true, animated: animated)
old.beginAppearanceTransition(false, animated: animated)
}

container.insertSubview(new.view, aboveSubview: old.view)
},
completion: {
old.view.removeFromSuperview()

if isVisible {
new.endAppearanceTransition()
old.endAppearanceTransition()
}

new.didMove(toParent: parent)
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@
/// 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`
/// 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
Expand All @@ -69,11 +72,13 @@
/// - update: Closure that updates the given view controller
public init<VC: UIViewController>(
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)

Expand Down Expand Up @@ -136,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

Expand All @@ -151,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
Expand Down
Loading