74

I've been following some tutorials to create custom animation while transitioning from one view to another.

My test project using custom segue from here works fine, but someone told me it's not encouraged anymore to do custom animation within a custom segue, and I should use UIViewControllerAnimatedTransitioning.

I followed several tutorials that make use of this protocol, but all of them are about modal presentation (for example this tutorial).

What I'm trying to do is a push segue inside a navigation controller tree, but when I try to do the same thing with a show (push) segue it doesn't work anymore.

Please tell me the correct way to do custom transitioning animation from one view to another in a navigation controller.

And is there anyway I can use one method for all transitioning animations? It would be awkward if one day I want to do the same animation but end up having to duplicate the code twice to work on modal vs controller transitioning.

AVAVT
  • 7,058
  • 2
  • 21
  • 44
  • @Rob Urgh I'm sorry if this sounds idiotic but how do I make my view controller the navigation controller's delegate? I can't seem to bind them on the storyboard and my 'navigationController:animationControllerForOperation: fromViewController:toViewController:' never get called. – AVAVT Oct 26 '14 at 04:03
  • You can just do `self.navigationController.delegate = self;` in `viewDidLoad`. You might get a warning unless you also specify in the `@interface` line that your view controller conforms to `` protocol. – Rob Oct 26 '14 at 04:04
  • This code is swift 4 for circular transition. https://stackoverflow.com/a/49321985/8334818 – Pramod More Mar 17 '18 at 11:32

4 Answers4

197

To do a custom transition with navigation controller (UINavigationController), you should:

  • Define your view controller to conform to UINavigationControllerDelegate protocol. For example, you can have a private class extension in your view controller's .m file that specifies conformance to this protocol:

    @interface ViewController () <UINavigationControllerDelegate>
    
    @end
    
  • Make sure you actually specify your view controller as your navigation controller's delegate:

    - (void)viewDidLoad {
        [super viewDidLoad];
    
        self.navigationController.delegate = self;
    }
    
  • Implement animationControllerForOperation in your view controller:

    - (id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                      animationControllerForOperation:(UINavigationControllerOperation)operation
                                                   fromViewController:(UIViewController*)fromVC
                                                     toViewController:(UIViewController*)toVC
    {
        if (operation == UINavigationControllerOperationPush)
            return [[PushAnimator alloc] init];
    
        if (operation == UINavigationControllerOperationPop)
            return [[PopAnimator alloc] init];
    
        return nil;
    }
    
  • Implement animators for push and pop animations, e.g.:

    @interface PushAnimator : NSObject <UIViewControllerAnimatedTransitioning>
    
    @end
    
    @interface PopAnimator : NSObject <UIViewControllerAnimatedTransitioning>
    
    @end
    
    @implementation PushAnimator
    
    - (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext
    {
        return 0.5;
    }
    
    - (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
    {
        UIViewController* toViewController   = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    
        [[transitionContext containerView] addSubview:toViewController.view];
    
        toViewController.view.alpha = 0.0;
    
        [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
            toViewController.view.alpha = 1.0;
        } completion:^(BOOL finished) {
            [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
        }];
    }
    
    @end
    
    @implementation PopAnimator
    
    - (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext
    {
        return 0.5;
    }
    
    - (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
    {
        UIViewController* toViewController   = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
        UIViewController* fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    
        [[transitionContext containerView] insertSubview:toViewController.view belowSubview:fromViewController.view];
    
        [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
            fromViewController.view.alpha = 0.0;
        } completion:^(BOOL finished) {
            [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
        }];
    }
    
    @end
    

    That does fade transition, but you should feel free to customize the animation as you see fit.

  • If you want to handle interactive gestures (e.g. something like the native swipe left-to-right to pop), you have to implement an interaction controller:

    • Define a property for an interaction controller (an object that conforms to UIViewControllerInteractiveTransitioning):

      @property (nonatomic, strong) UIPercentDrivenInteractiveTransition *interactionController;
      

      This UIPercentDrivenInteractiveTransition is a nice object that does the heavy lifting of updating your custom animation based upon how complete the gesture is.

    • Add a gesture recognizer to your view. Here I'm just implementing the left gesture recognizer to simulate a pop:

      UIScreenEdgePanGestureRecognizer *edge = [[UIScreenEdgePanGestureRecognizer alloc] initWithTarget:self action:@selector(handleSwipeFromLeftEdge:)];
      edge.edges = UIRectEdgeLeft;
      [view addGestureRecognizer:edge];
      
    • Implement the gesture recognizer handler:

      /** Handle swipe from left edge
       *
       * This is the "action" selector that is called when a left screen edge gesture recognizer starts.
       *
       * This will instantiate a UIPercentDrivenInteractiveTransition when the gesture starts,
       * update it as the gesture is "changed", and will finish and release it when the gesture
       * ends.
       *
       * @param   gesture       The screen edge pan gesture recognizer.
       */
      
      - (void)handleSwipeFromLeftEdge:(UIScreenEdgePanGestureRecognizer *)gesture {
          CGPoint translate = [gesture translationInView:gesture.view];
          CGFloat percent   = translate.x / gesture.view.bounds.size.width;
      
          if (gesture.state == UIGestureRecognizerStateBegan) {
              self.interactionController = [[UIPercentDrivenInteractiveTransition alloc] init];
              [self popViewControllerAnimated:TRUE];
          } else if (gesture.state == UIGestureRecognizerStateChanged) {
              [self.interactionController updateInteractiveTransition:percent];
          } else if (gesture.state == UIGestureRecognizerStateEnded) {
              CGPoint velocity = [gesture velocityInView:gesture.view];
              if (percent > 0.5 || velocity.x > 0) {
                  [self.interactionController finishInteractiveTransition];
              } else {
                  [self.interactionController cancelInteractiveTransition];
              }
              self.interactionController = nil;
          }
      }
      
    • In your navigation controller delegate, you also have to implement interactionControllerForAnimationController delegate method

      - (id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController
                               interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController {
          return self.interactionController;
      }
      

If you google "UINavigationController custom transition tutorial" and you'll get many hits. Or see WWDC 2013 Custom Transitions video.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Along with that how to retain the default swipe to back (Screen Edge Gesture)? – kidsid49 Sep 28 '15 at 11:32
  • @kidsid49 - When you do custom transitions, you lose the built in interactive pop gesture, but you also have the option to implement your own interaction controllers. See that WWDC video for more information. Refer to the discussion about `UIPercentDrivenInteractiveTransition` (which greatly simplifies the process). I've added some relevant code snippets above. – Rob Sep 28 '15 at 16:42
  • @Rob Thank you for putting up such a detailed answer, I was able to add custom animation to my app successfully. I do have a query though, I added a custom pop animation so that when the user swipes from left to right, the view from the left comes in, just as with a normal pop without custom animations. The problem is that I cant seem to get the view, coming in from the left, to exactly follow my thumb/finger, there is always some amount of distance between where I touch and where the left views' boundary is, how would I make it such that the view boundary is exactly where I am touching? – Shumais Ul Haq Mar 12 '16 at 12:32
  • How do You fire this push/pop transition. I tried creating segues (Show e.g. Push) in storyboard and using method performSegueWithIdentifier from viewControllers on the stack in NavigationController with delegate that is handling transition in the way it's described but it doesn't work. So how to make this transition start? – Marcin Kapusta May 16 '16 at 14:12
  • If invoking simple "Show (e.g. Push)" segue from a scene embedded in a navigation controller via `performSegueWithIdentifier`, the above works fine. Assuming you're seeing the push navigation without your custom animation, I'd just add breakpoints or log statements in your various methods and figure out where it went wrong (e.g. was `animationControllerForOperation` called at all?). But we're not going to be able to diagnose this here in comments, so do a little more diagnostics and then post your own question if you still can't resolve it. – Rob May 16 '16 at 18:50
  • @ShumaisUlHaq - If you want it to exactly follow your thumb/finger, I'd use `UIViewAnimationOptionCurveLinear` in the `animateWithDuration:delay:options:...` call. Also, if you want it to be even better tracking, you could use predictive touches, which will reduce the perceived latency even more. – Rob May 16 '16 at 19:26
  • hey @Rob .. can we do `UIPercentDrivenInteractiveTransition` with pangesture with pushing `instantiateViewController`? – Bhavin Bhadani Jan 09 '17 at 06:48
  • @EICaptainv2.0 - Yep, you create a swipe from right edge, just like the swipe from left edge, above. And in it's `UIGestureRecognizerStateBegan`, rather than popping, you create an instance of the VC you're pushing to and then push to it, and you obviously have to flip the sign of the `percent` algorithm, because you're swiping the other direction. – Rob Jun 24 '17 at 06:55
  • @Rob hey so I've implemented my own custom animation class which uses the navigation delegate, the thing is, subsequent view controller's that don't require the custom animations still have the animator trying to handle the animations. And that's because the navigation controller's delegate is still set to the view controller that wanted the custom animation in the first place. How do I overcome this issue where subsequent view controllers still get handled by that delegate? Is there a way to correctly remove the animator class from the navigation controller? – Pavan Jul 25 '17 at 17:48
  • 1
    @Pavan - Your `animationControllerForOperation` can just check the `fromVC` and/or `toVC`, and return `nil` if you don't want it to perform custom animation. Simplest is to just check to see if it's a particular class. More elegantly, you might design an optional protocol to which view controllers can conform, with some boolean property to indicate whether they want the custom push/pop or not. – Rob Jul 25 '17 at 18:25
  • If anyone will have the same problem as me: Using the custom push and pop animator objects to push and pop a new view controller into and out of the navigation controller's stack disrupted the functionality of `handleSwipeFromLeftEdge`. It didn't work as expected (moved to fast and not with the speed of the finder). If you have the same problem add the `UIScreenEdgePanGestureRecognizer` not the view, but to its `superview` property. That solved the problem for me. Can't explain why, but it works. – Baran Dec 04 '17 at 20:30
  • After moving some code around that didn't work anymore. In your pop/push Animator object do NOT use `animate(withDuration:animations:)` if you want to support percent driven interaction. Use `transition(with:duration:options:animations:completion:)`. Here a example: `UIView.transition(with: transitionContext.containerView, duration: animationDuration, options: .curveLinear, animations: { /* change view frames/ alpha values here */ }`. Write a animator object that uses the animation transition method and return the custom animator only when `handleSwipeFromLeftEdge ` is invoked. – Baran Dec 05 '17 at 10:31
  • I'm able to swipe back to the previous VC without adding the edge pan gesture recognizer by setting the navigation controller delegate to the presented/pushed VC. After swiping back, also need to set the navigation controller delegate back to the original VC in ViewWillAppear(). – nananta Feb 26 '18 at 21:30
15

You may wanna add the following code before addSubview

  toViewController.view.frame = [transitionContext finalFrameForViewController:toViewController];

From another question custom-transition-for-push-animation-with-navigationcontroller-on-ios-9

From Apple's Documentation for finalFrameForViewController:

Returns the ending frame rectangle for the specified view controller’s view.

The rectangle returned by this method represents the size of the corresponding view at the end of the transition. For the view being covered during the presentation, the value returned by this method might be CGRectZero but it might also be a valid frame rectangle.

Community
  • 1
  • 1
Q i
  • 320
  • 5
  • 12
  • 4
    Wow, THIS fixes my problem. Why does this piece of information seem to be nowhere else that I looked? Like Apple documentation, other tutorials... It makes no sense to me that you have to set this. – David Feb 16 '17 at 06:13
  • 1
    Point being that I am using autolayout and set the frame nowhere else... – David Feb 16 '17 at 06:15
9

Using Rob's & Q i's perfect answers, here is the simplified Swift code, using the same fade animation for .push and .pop:

extension YourViewController: UINavigationControllerDelegate {
    func navigationController(_ navigationController: UINavigationController,
                              animationControllerFor operation: UINavigationControllerOperation,
                              from fromVC: UIViewController,
                              to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {

        //INFO: use UINavigationControllerOperation.push or UINavigationControllerOperation.pop to detect the 'direction' of the navigation

        class FadeAnimation: NSObject, UIViewControllerAnimatedTransitioning {
            func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
                return 0.5
            }

            func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
                let toViewController = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)
                if let vc = toViewController {
                    transitionContext.finalFrame(for: vc)
                    transitionContext.containerView.addSubview(vc.view)
                    vc.view.alpha = 0.0
                    UIView.animate(withDuration: self.transitionDuration(using: transitionContext),
                    animations: {
                        vc.view.alpha = 1.0
                    },
                    completion: { finished in
                        transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
                    })
                } else {
                    NSLog("Oops! Something went wrong! 'ToView' controller is nill")
                }
            }
        }

        return FadeAnimation()
    }
}

Do not forget to set the delegate in YourViewController's viewDidLoad() method:

override func viewDidLoad() {
    //...
    self.navigationController?.delegate = self
    //...
}
7

It works both swift 3 and 4

@IBAction func NextView(_ sender: UIButton) {
  let newVC = self.storyboard?.instantiateViewControllerWithIdentifier(withIdentifier: "NewVC") as! NewViewController

  let transition = CATransition()
  transition.duration = 0.5
  transition.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
  transition.type = kCATransitionPush
  transition.subtype = kCAGravityLeft
  //instead "kCAGravityLeft" try with different transition subtypes

  self.navigationController?.view.layer.add(transition, forKey: kCATransition)
  self.navigationController?.pushViewController(newVC, animated: false)
}
budiDino
  • 13,044
  • 8
  • 95
  • 91
Sai kumar Reddy
  • 1,751
  • 20
  • 23