Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I present a view controller over current context with a custom transition?

I am running into a problem with view controller containment and wanting to present view controllers "over current context" with a custom presentation/animation.

I have a root view controller that has two child view controllers that can be added and removed as children to the root. When these child view controllers present a view controller I want the presentation to be over current context so that when the child that is presenting is removed from the view heirarchy and deallocated the presented modal will be removed as well. Also, if child A presents a view controller, I would expect child B's 'presentedViewController' property to be nil in an "over current context" presentation even if A was still presenting.

Everything works as expected when I set the modalPresentationStyle of my presented view controller to overCurrentContext, and if the child view controllers have definesPresentationContext set to true.

This doesn't work when I would expect it to however if I have modalPresentationStyle set to custom and override shouldPresentInFullscreen returning false in my custom presentation controller.

Here is an example illustrating the problem:

import UIKit

final class ProgressController: UIViewController {
    private lazy var activityIndicatorView = UIActivityIndicatorView(activityIndicatorStyle: .white)
    private lazy var progressTransitioningDelegate = ProgressTransitioningDelegate()

    // MARK: Lifecycle

    override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)

        setup()
    }

    required public init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        setup()
    }

    override public func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = UIColor(white: 0, alpha: 0)

        view.addSubview(activityIndicatorView)
        activityIndicatorView.startAnimating()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    }

    override public func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        activityIndicatorView.center = CGPoint(x: view.bounds.width/2, y: view.bounds.height/2)
    }

    // MARK: Private

    private func setup() {
        modalPresentationStyle = .custom
        modalTransitionStyle = .crossDissolve
        transitioningDelegate = progressTransitioningDelegate
    }
}

final class ProgressTransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
    func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
        return DimBackgroundPresentationController(presentedViewController: presented, presenting: source)
    }
}

final class DimBackgroundPresentationController: UIPresentationController {
    lazy var overlayView: UIView = {
        let v = UIView()
        v.backgroundColor = UIColor(white: 0, alpha: 0.5)
        return v
    }()

    override var shouldPresentInFullscreen: Bool {
        return false
    }

    override func presentationTransitionWillBegin() {
        super.presentationTransitionWillBegin()

        overlayView.alpha = 0
        containerView!.addSubview(overlayView)
        containerView!.addSubview(presentedView!)

        if let coordinator = presentedViewController.transitionCoordinator {
            coordinator.animate(alongsideTransition: { _ in
                self.overlayView.alpha = 1
            }, completion: nil)
        }
    }

    override func containerViewDidLayoutSubviews() {
        super.containerViewDidLayoutSubviews()

        overlayView.frame = presentingViewController.view.bounds
    }

    override func dismissalTransitionWillBegin() {
        super.dismissalTransitionWillBegin()

        let coordinator = presentedViewController.transitionCoordinator
        coordinator?.animate(alongsideTransition: { _ in
            self.overlayView.alpha = 0
        }, completion: nil)
    }
}

class ViewControllerA: UIViewController {
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        definesPresentationContext = true

        let vc = ProgressController()
        self.present(vc, animated: true) {
        }
    }
}

class ViewController: UIViewController {

    let container = UIScrollView()

    override func viewDidLoad() {
        super.viewDidLoad()

        container.frame = view.bounds
        view.addSubview(container)

        let lhs = ViewControllerA()
        lhs.view.backgroundColor = .red

        let rhs = UIViewController()
        rhs.view.backgroundColor = .blue

        addChildViewController(lhs)
        lhs.view.frame = CGRect(x: 0, y: 0, width: view.bounds.width, height: view.bounds.height)
        container.addSubview(lhs.view)
        lhs.didMove(toParentViewController: self)

        addChildViewController(rhs)
        rhs.view.frame = CGRect(x: view.bounds.width, y: 0, width: view.bounds.width, height: view.bounds.height)
        container.addSubview(rhs.view)
        rhs.didMove(toParentViewController: self)


        container.contentSize = CGSize(width: view.bounds.width * 2, height: view.bounds.height)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
//        let rect = CGRect(x: floor(view.bounds.width/2.0), y: 0, width: view.bounds.width, height: view.bounds.height)
//        container.scrollRectToVisible(rect, animated: true)
    }
}
  1. This will show a ViewController with a scroll view that contains two child view controllers
  2. The red view controller on the left hand side will present a progress view controller that should present over current context.
  3. If the view controller was presented properly "over current context" then you would be able to scroll the scrollview, and if you checked the "presentedViewController" property of the blue right hand side view controller then it should be nil. Neither of these are true.

If you change the setup() function on ProgressController to:

private func setup() {
    modalPresentationStyle = .overCurrentContext
    modalTransitionStyle = .crossDissolve
    transitioningDelegate = progressTransitioningDelegate
}

the presentation/view heirarchy will behave as expected, but the custom presentation will not be used.

Apple's docs for shouldPresentInFullscreen seem to indicate that this should work:

The default implementation of this method returns true, indicating that the presentation covers the entire screen. You can override this method and return false to force the presentation to display only in the current context.

If you override this method, do not call super.

I tested this in Xcode 8 on iOS 10 and Xcode 9 on iOS 11 and the above code would not work as expected in either case.

like image 562
pmick Avatar asked Aug 30 '17 17:08

pmick


3 Answers

I'm going to guess that you've found a bug. The docs say that shouldPresentInFullscreen can turn this into a currentContext presentation, but it does nothing. (In addition to your test and my test, I found a few online complaints about this, leading me to think that that's the case.)

The conclusion is that you cannot get the default currentContext behavior (where the runtime consults the source view controller and up the hierarchy looking for definesPresentationContext) if you use presentation style of .custom.

I suggest filing a bug with Apple.

like image 78
matt Avatar answered Oct 07 '22 09:10

matt


I ran into this same problem myself recently. You're right that UIKit documentation seems to be misleading about the behavior of UIPresentationController.shouldPresentInFullscreen. However, when experimenting with this property, I discovered that when it's set to NO, the UITransitionView is added to the window, but when it's set to YES, it's added to UITransitionView of the nearest presented UIViewController. This does not include child UIViewControllers whose definesPresentationContext property returns YES.

However, if you present a UIViewController with UIModalPresentationStyleOverCurrentContext on a UIViewController with definesPresentationContext == YES, then UIKit inserts a presentation container view in the hierarchy above the presenting view controller. If you present upon that presented UIViewController using UIModalPresentationStyleCustom with a UIPresentationController whose shouldPresentInFullscreen property returns NO, the presented UIViewController's presentation container view will be inserted into the first UIViewController that was presented over the current context of the child UIViewController.

like image 2
Kurt Horimoto Avatar answered Oct 07 '22 10:10

Kurt Horimoto


I also think that shouldPresentInFullscreen does essentially nothing. A bug if you ask me.

To fix this, you should override frameOfPresentedViewInContainerView and return the adjusted frame.

Then, override containerViewWillLayoutSubviews and set self.presentedViewController.view.frame = self.frameOfPresentedViewInContainerView. Here you should also set the frames of any background or chrome views you might have.

like image 1
Ortwin Gentz Avatar answered Oct 07 '22 10:10

Ortwin Gentz