4
votes

I've implemented a Navigation controller to incorporate an rotating-disc type of layout (imagine each VC laid out in a circle, that rotates as a whole, into view sequentially. The navigation controller is configured to use a custom transition class, as below :-

import UIKit

class RotaryTransition: NSObject, UIViewControllerAnimatedTransitioning {
    let isPresenting :Bool
    let duration :TimeInterval = 0.5
    let animationDuration: TimeInterval = 0.7
    let delay: TimeInterval = 0
    let damping: CGFloat = 1.4
    let spring: CGFloat = 6.0

    init(isPresenting: Bool) {
        self.isPresenting = isPresenting
        super.init()
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //Get references to the view hierarchy
        let fromViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)!
        let toViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)!
        let sourceRect: CGRect = transitionContext.initialFrame(for: fromViewController)
        let containerView: UIView = transitionContext.containerView

        if self.isPresenting { // Push
            //1. Settings for the fromVC ............................
//            fromViewController.view.frame = sourceRect
            fromViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
            fromViewController.view.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3);

            //2. Setup toVC view...........................
            containerView.insertSubview(toViewController.view, belowSubview:fromViewController.view)
            toViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
            toViewController.view.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3);
            toViewController.view.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180));

            //3. Perform the animation...............................
            UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: {
                fromViewController.view.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180));
                toViewController.view.transform = CGAffineTransform(rotationAngle: 0);
            }, completion: {
                (animated: Bool) -> () in transitionContext.completeTransition(true)
            })
        } else { // Pop
            //1. Settings for the fromVC ............................
            fromViewController.view.frame = sourceRect
            fromViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
            fromViewController.view.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3);

            //2. Setup toVC view...........................
//            toViewController.view.frame = transitionContext.finalFrame(for: toViewController)
            toViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
            toViewController.view.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3);
            toViewController.view.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180));
            containerView.insertSubview(toViewController.view, belowSubview:fromViewController.view)

            //3. Perform the animation...............................
            UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: {
                fromViewController.view.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180));
                toViewController.view.transform = CGAffineTransform(rotationAngle: 0);
            }, completion: {
                //When the animation is completed call completeTransition
                (animated: Bool) -> () in transitionContext.completeTransition(true)
            })            
        }
    }

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

A representation of how the views move is show in the illustration below... The two red areas are the problems, as explained later.

enter image description here

The presenting (push) translation works fine - 2 moves to 1 and 3 moves to 2. However, the dismissing (pop) translation does not, whereby the dismissing VC moves out of view seemingly correctly (2 moving to 3), but the presenting (previous) VC arrives either in the wrong place, or with an incorrectly sized frame...

With the class as-is, the translation results in 2 moving to 3 (correctly) but 1 then moves to 4, the view is correctly sized, yet seems offset, by a seemingly arbitrary distance, from the intended location. I have since tried a variety of different solutions.

In the pop section I tried adding the following line (commented in the code) :-

toViewController.view.frame = transitionContext.finalFrame(for: toViewController)

...but the VC now ends up being shrunk (1 moves to 5). I hope someone can see the likely stupid error I'm making. I tried simply duplicating the push section to the pop section (and reversing everything), but it just doesn't work!

FYI... Those needing to know how to hookup the transition to a UINavigationController - Add the UINavigationControllerDelegate to your nav controller, along with the following function...

    func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationControllerOperation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
        let transition: SwingTransition = SwingTransition.init(isPresenting: ( operation == .push ? true : false ))
        return transition;
    }

The diagram below shows how all views would share the same originating point (for the translation). The objective is to give the illusion of a revolver barrel moving each VC into view. The top centre view represents the viewing window, showing the third view in the stack. Apologies for the poor visuals...

enter image description here

2

2 Answers

5
votes

The problem is that one of the properties in the restored view controller's view isn't getting reset properly. I'd suggest resetting it when the animation is done (you probably don't want to keep the non-standard transform and anchorPoint in case you do other animations later that presume the view is not transformed). So, in the completion block of the animation, reset the position, anchorPoint and transform of the views.

class RotaryTransition: NSObject, UIViewControllerAnimatedTransitioning {
    let isPresenting: Bool
    let duration: TimeInterval = 0.5
    let delay: TimeInterval = 0
    let damping: CGFloat = 1.4
    let spring: CGFloat = 6

    init(isPresenting: Bool) {
        self.isPresenting = isPresenting
        super.init()
    }

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        let from   = transitionContext.viewController(forKey: .from)!
        let to     = transitionContext.viewController(forKey: .to)!
        let frame  = transitionContext.initialFrame(for: from)
        let height = frame.size.height
        let width  = frame.size.width

        let angle: CGFloat = 15.0 * .pi / 180.0
        let rotationCenterOffset: CGFloat = width / 2 / tan(angle / 2) / height + 1  // use fixed value, e.g. 3, if you want, or use this to ensure that the corners of the two views just touch, but don't overlap

        let rightTransform  = CATransform3DMakeRotation(angle, 0, 0, 1)
        let leftTransform   = CATransform3DMakeRotation(-angle, 0, 0, 1)

        transitionContext.containerView.insertSubview(to.view, aboveSubview: from.view)

        // save the anchor and position

        let anchorPoint = from.view.layer.anchorPoint
        let position    = from.view.layer.position

        // prepare `to` layer for rotation

        to.view.layer.anchorPoint = CGPoint(x: 0.5, y: rotationCenterOffset)
        to.view.layer.position = CGPoint(x: width / 2, y: height * rotationCenterOffset)
        to.view.layer.transform = self.isPresenting ? rightTransform : leftTransform
        //to.view.layer.opacity = 0

        // prepare `from` layer for rotation

        from.view.layer.anchorPoint = CGPoint(x: 0.5, y: rotationCenterOffset)
        from.view.layer.position = CGPoint(x: width / 2, y: height * rotationCenterOffset)

        // rotate

        UIView.animate(withDuration: duration, delay: delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, animations: {
            from.view.layer.transform = self.isPresenting ? leftTransform : rightTransform
            to.view.layer.transform = CATransform3DIdentity
            //to.view.layer.opacity = 1
            //from.view.layer.opacity = 0
        }, completion: { finished in
            // restore the layers to their default configuration

            for view in [to.view, from.view] {
                view?.layer.transform = CATransform3DIdentity
                view?.layer.anchorPoint = anchorPoint
                view?.layer.position = position
                //view?.layer.opacity = 1
            }

            transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
        })
    }

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

I did a few other sundry changes, while I was here:

  • eliminated the semicolons;
  • eliminated one of the duration properties;
  • fixed the name of the parameter of the completion closure of animate method to finished rather than animated to more accurately reflect what it's real purpose is ... you could use _, too;
  • set the completeTransition based upon whether the animation was canceled or not (because if you ever make this interactive/cancelable, you don't want to always use true);
  • use .pi rather than M_PI;
  • I commented out my adjustments of opacity, but I generally do that to give the effect a touch more polish and to ensure that if you tweak angles so the views overlap, you don't get any weird artifacts of the other view just as the animation starts or just as its finishing; I've actually calculated the parameters so there's no overlapping, regardless of screen dimensions, so that wasn't necessary and I commented out the opacity lines, but you might consider using them, depending upon the desired effect.

Previously I showed how to simplify the process a bit, but the resulting effect wasn't exactly what you were looking for, but see the previous rendition of this answer if you're interested.

4
votes

Your problem is a common one that happens when you do custom view controller transitions. I know this because I've done it a lot :)

You're looking for a problem in the pop transition, but the actual problem is in the push. If you inspect the view of the first controller in the stack after the transition, you'll see that it has an unusual frame, because you've messed about with its transform and anchor point and layer position and so forth. Really, you need to clean all that up before you end the transition, otherwise it bites you later on, as you're seeing in the pop.

A much simpler and safer way to do your custom transitions is to add a "canvas" view, then to that canvas add snapshots of your outgoing and incoming views instead and manipulate those. This means you have no cleanup at the end of the transition - just remove the canvas view. I've written about this technique here. For your case, I added the following convenience method:

extension UIView {    
    func snapshot(view: UIView, afterUpdates: Bool) -> UIView? {
        guard let snapshot = view.snapshotView(afterScreenUpdates: afterUpdates) else { return nil }
        self.addSubview(snapshot)
        snapshot.frame = convert(view.bounds, from: view)
        return snapshot
    }
}

Then updated your transition code to move the snapshots around on a canvas view instead:

    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        //Get references to the view hierarchy
        let fromViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)!
        let toViewController: UIViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)!
        let sourceRect: CGRect = transitionContext.initialFrame(for: fromViewController)
        let containerView: UIView = transitionContext.containerView

        // The canvas is used for all animation and discarded at the end
        let canvas = UIView(frame: containerView.bounds)
        containerView.addSubview(canvas)

        let fromView = transitionContext.view(forKey: .from)!
        let toView = transitionContext.view(forKey: .to)!
        toView.frame = transitionContext.finalFrame(for: toViewController)
        toView.layoutIfNeeded()
        let toSnap = canvas.snapshot(view: toView, afterUpdates: true)!

        if self.isPresenting { // Push
            //1. Settings for the fromVC ............................
            //            fromViewController.view.frame = sourceRect
            let fromSnap = canvas.snapshot(view: fromView, afterUpdates: false)!
            fromView.removeFromSuperview()
            fromSnap.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
            fromSnap.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3);

            //2. Setup toVC view...........................
            toSnap.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
            toSnap.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3);
            toSnap.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180));

            //3. Perform the animation...............................
            UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: {
                fromSnap.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180));
                toSnap.transform = CGAffineTransform(rotationAngle: 0);
            }, completion: {
                (animated: Bool) -> () in
                containerView.insertSubview(toViewController.view, belowSubview:canvas)
                canvas.removeFromSuperview()
                transitionContext.completeTransition(true)
            })
        } else { // Pop
            //1. Settings for the fromVC ............................
            fromViewController.view.frame = sourceRect
            fromViewController.view.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
            fromViewController.view.layer.position = CGPoint(x: fromViewController.view.frame.size.width/2, y: fromViewController.view.frame.size.height * 3);

            //2. Setup toVC view...........................
            let toSnap = canvas.snapshot(view: toView, afterUpdates: true)!

            toSnap.layer.anchorPoint = CGPoint(x: 0.5, y: 3);
            toSnap.layer.position = CGPoint(x: toViewController.view.frame.size.width/2, y: toViewController.view.frame.size.height * 3);
            toSnap.transform = CGAffineTransform(rotationAngle: -15 * CGFloat(M_PI / 180));

            //3. Perform the animation...............................
            UIView.animate(withDuration: animationDuration, delay:delay, usingSpringWithDamping: damping, initialSpringVelocity: spring, options: [], animations: {
                fromViewController.view.transform = CGAffineTransform(rotationAngle: 15 * CGFloat(M_PI / 180));
                toSnap.transform = CGAffineTransform(rotationAngle: 0);
            }, completion: {
                //When the animation is completed call completeTransition
                (animated: Bool) -> () in
                containerView.insertSubview(toViewController.view, belowSubview: canvas)
                canvas.removeFromSuperview()
                transitionContext.completeTransition(true)
            })
        }
    }

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

This particular transition is pretty simple so it's not too difficult to reset the properties of the view frames, but if you do anything more complex then the canvas and snapshot approach works really well, so I tend to just use it everywhere.