theoadahl
theoadahl

Reputation: 524

Swift: Instantiate a view controller for custom transition in the current navigation stack

Introduction

I'm creating an app that has, in its rootViewController, a UITableView and a UIPanGestureRecognizer attached to a small UIView acting as a "handle" which enables a custom view controller transition for a UIViewController called "SlideOutViewController" to be panned from the right.

Issue

I have noticed two issues with my approach. But the actual custom transition works as expected.

  1. When the SlideOutViewController is created it is not attached to the navigation stack I believe, therefore it has no associated navigationBar. And if I use the navigationController to push it on the stack, I loose the interactive transition.

  2. Side note: I have not found a way to connect the handle to the SlideOutViewController that is interactively dragged out. So the translation of the handle is not consistent with the SlideOutViewControllers position.

Question

My code

In the rootViewController.

class RootViewController: UIViewController {

    ...

    let slideControllerHandle = UIView()
    var interactionController : UIPercentDrivenInteractiveTransition?

    override func viewDidLoad() {
        super.viewDidLoad()

        ... // Setting up the table view etc...

        setupPanGForSlideOutController()
    }

    private func setupPanGForSlideOutController() {
         slideControllerHandle.translatesAutoresizingMaskIntoConstraints = false
         slideControllerHandle.layer.borderColor = UIColor.black.cgColor
         slideControllerHandle.layer.borderWidth = 1
         slideControllerHandle.layer.cornerRadius = 30
         view.addSubview(slideControllerHandle)
         slideControllerHandle.frame = CGRect(x: view.frame.width - 12.5, y: view.frame.height / 2, width: 25, height: 60)
         let panGestureForCalendar = UIPanGestureRecognizer(target: self, action: #selector(handlePanGestureForSlideOutViewController(_:)))
         slideControllerHandle.addGestureRecognizer(panGestureForCalendar)
    }

    @objc private func handlePanGestureForSlideOutViewController(_ gesture: UIPanGestureRecognizer) {

         let xPosition = gesture.location(in: view).x
         let percent = 1 - (xPosition / view.frame.size.width)

         switch gesture.state {
         case .began:
             guard let slideOutController = storyboard?.instantiateViewController(withIdentifier: "CNSlideOutViewControllerID") as? SlideOutViewController else { fatalError("Sigh...") }
             interactionController = UIPercentDrivenInteractiveTransition()
        slideOutController.customTransitionDelegate.interactionController = interactionController
             self.present(slideOutController, animated: true)
         case .changed:
             slideControllerHandle.center = CGPoint(x: xPosition, y: slideControllerHandle.center.y)
             interactionController?.update(percent)
         case .ended, .cancelled:
             let velocity = gesture.velocity(in: view)
             interactionController?.completionSpeed = 0.999
             if percent > 0.5 || velocity.x < 10 {
                 UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: {
                     self.slideControllerHandle.center = CGPoint(x: self.view.frame.width, y: self.slideControllerHandle.center.y)
                 })
                 interactionController?.finish()
             } else {
                 UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.5, options: .curveEaseInOut, animations: {
                     self.slideControllerHandle.center = CGPoint(x: -25, y: self.slideControllerHandle.center.y)
                 })
                 interactionController?.cancel()
             }
             interactionController = nil
         default:
             break
         }
    }

The SlideOutViewController

class SlideOutViewController: UIViewController {

    var interactionController : UIPercentDrivenInteractiveTransition?
    let customTransitionDelegate = TransitionDelegate()

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        modalPresentationStyle = .custom
        transitioningDelegate = customTransitionDelegate
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .red
        navigationItem.title = "Slide Controller"
        let addButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addNewData(_:)))
        navigationItem.setRightBarButton(addButton, animated: true)
    }

}

The custom transition code. Based on Rob's descriptive answer on this SO question

  1. TransitionDelegate

    class TransitioningDelegate: NSObject, UIViewControllerTransitioningDelegate {
    
        weak var interactionController : UIPercentDrivenInteractiveTransition?
        func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
            return CNRightDragAnimationController(transitionType: .presenting)
        }
    
        func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
            return CNRightDragAnimationController(transitionType: .dismissing)
        }
    
        func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
            return PresentationController(presentedViewController: presented, presenting: presenting)
        }
    
        func interactionControllerForPresentation(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
            return interactionController
        }    
    
        func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
            return interactionController
        }
    }
    
  2. DragAnimatedTransitioning

    class CNRightDragAnimationController: NSObject, UIViewControllerAnimatedTransitioning {
    
        enum TransitionType {
            case presenting
            case dismissing
        }
    
        let transitionType: TransitionType
    
        init(transitionType: TransitionType) {
            self.transitionType = transitionType
            super.init()
        }
    
        func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
            let inView   = transitionContext.containerView
            let toView   = transitionContext.view(forKey: .to)!
            let fromView = transitionContext.view(forKey: .from)!
    
            var frame = inView.bounds
    
            switch transitionType {
            case .presenting:
                frame.origin.x = frame.size.width
                toView.frame = frame
    
                inView.addSubview(toView)
                UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
                    toView.frame = inView.bounds
                }, completion: { finished in
                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                })
            case .dismissing:
                toView.frame = frame
                inView.insertSubview(toView, belowSubview: fromView)
    
                UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
                    frame.origin.x = frame.size.width
                    fromView.frame = frame
                }, completion: { finished in
                    transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                })
            }
        }
    
        func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
            return 0.5
        }
    }
    
  3. PresentationController

    class PresentationController: UIPresentationController {
    
        override var shouldRemovePresentersView: Bool { return true }
    }
    

Thanks for reading my question.

Upvotes: 1

Views: 1107

Answers (1)

Rob
Rob

Reputation: 437532

The animation code you’ve taken this from is for custom “present” (e.g. modal) transitions. But if you want a custom navigation as you push/pop when using a navigation controller, you specify a delegate for your UINavigationController and then return the appropriate transitioning delegate in navigationController(_:animationControllerFor:from:to:). And also implement navigationController(_:interactionControllerFor:) and return your interaction controller there.


E.g. I'd do something like:

class FirstViewController: UIViewController {

    let navigationDelegate = CustomNavigationDelegate()

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationController?.delegate = navigationDelegate
        navigationDelegate.addPushInteractionController(to: view)
    }

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

        navigationDelegate.pushDestination = { [weak self] in
            self?.storyboard?.instantiateViewController(withIdentifier: "Second")
        }
    }
}

Where:

class CustomNavigationDelegate: NSObject, UINavigationControllerDelegate {

    var interactionController: UIPercentDrivenInteractiveTransition?
    var current: UIViewController?
    var pushDestination: (() -> UIViewController?)?

    func navigationController(_ navigationController: UINavigationController,
                              animationControllerFor operation: UINavigationController.Operation,
                              from fromVC: UIViewController,
                              to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        return CustomNavigationAnimator(transitionType: operation)
    }

    func navigationController(_ navigationController: UINavigationController,
                              interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
        return interactionController
    }

    func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
        current = viewController
    }
}

// MARK: - Push

extension CustomNavigationDelegate {
    func addPushInteractionController(to view: UIView) {
        let swipe = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePushGesture(_:)))
        swipe.edges = [.right]
        view.addGestureRecognizer(swipe)
    }

    @objc private func handlePushGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
        guard let pushDestination = pushDestination else { return }

        let position = gesture.translation(in: gesture.view)
        let percentComplete = min(-position.x / gesture.view!.bounds.width, 1.0)

        switch gesture.state {
        case .began:
            interactionController = UIPercentDrivenInteractiveTransition()
            guard let controller = pushDestination() else { fatalError("No push destination") }
            current?.navigationController?.pushViewController(controller, animated: true)

        case .changed:
            interactionController?.update(percentComplete)

        case .ended, .cancelled:
            let speed = gesture.velocity(in: gesture.view)
            if speed.x < 0 || (speed.x == 0 && percentComplete > 0.5) {
                interactionController?.finish()
            } else {
                interactionController?.cancel()
            }
            interactionController = nil

        default:
            break
        }
    }
}

// MARK: - Pop

extension CustomNavigationDelegate {
    func addPopInteractionController(to view: UIView) {
        let swipe = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handlePopGesture(_:)))
        swipe.edges = [.left]
        view.addGestureRecognizer(swipe)
    }

    @objc private func handlePopGesture(_ gesture: UIScreenEdgePanGestureRecognizer) {
        let position = gesture.translation(in: gesture.view)
        let percentComplete = min(position.x / gesture.view!.bounds.width, 1)

        switch gesture.state {
        case .began:
            interactionController = UIPercentDrivenInteractiveTransition()
            current?.navigationController?.popViewController(animated: true)

        case .changed:
            interactionController?.update(percentComplete)

        case .ended, .cancelled:
            let speed = gesture.velocity(in: gesture.view)
            if speed.x > 0 || (speed.x == 0 && percentComplete > 0.5) {
                interactionController?.finish()
            } else {
                interactionController?.cancel()
            }
            interactionController = nil


        default:
            break
        }
    }
}

And

class CustomNavigationAnimator: NSObject, UIViewControllerAnimatedTransitioning {

    let transitionType: UINavigationController.Operation

    init(transitionType: UINavigationController.Operation) {
        self.transitionType = transitionType
        super.init()
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let inView   = transitionContext.containerView
        let toView   = transitionContext.view(forKey: .to)!
        let fromView = transitionContext.view(forKey: .from)!

        var frame = inView.bounds

        switch transitionType {
        case .push:
            frame.origin.x = frame.size.width
            toView.frame = frame

            inView.addSubview(toView)
            UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
                toView.frame = inView.bounds
            }, completion: { finished in
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            })

        case .pop:
            toView.frame = frame
            inView.insertSubview(toView, belowSubview: fromView)

            UIView.animate(withDuration: transitionDuration(using: transitionContext), animations: {
                frame.origin.x = frame.size.width
                fromView.frame = frame
            }, completion: { finished in
                transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
            })

        case .none:
            break
        }
    }

    func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        return 0.5
    }
}

Then, if the second view controller wanted to have the custom interactive pop plus the ability to swipe to the third view controller:

class SecondViewController: UIViewController {

    var navigationDelegate: CustomNavigationDelegate { return navigationController!.delegate as! CustomNavigationDelegate }

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationDelegate.addPushInteractionController(to: view)
        navigationDelegate.addPopInteractionController(to: view)
    }

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

        navigationDelegate.pushDestination = { [weak self] in
            self?.storyboard?.instantiateViewController(withIdentifier: "Third")
        }
    }

}

But if the last view controller can't push to anything, but only pop:

class ThirdViewController: UIViewController {

    var navigationDelegate: CustomNavigationDelegate { return navigationController!.delegate as! CustomNavigationDelegate }

    override func viewDidLoad() {
        super.viewDidLoad()

        navigationDelegate.addPopInteractionController(to: view)
    }

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

        navigationDelegate.pushDestination = nil
    }

}

Upvotes: 1

Related Questions