Navigation controller custom transition animation
Asked Answered
R

4

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.

Rhys answered 26/10, 2014 at 3:22 Comment(3)
@Wineskin 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.Rhys
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 <UINavigationControllerDelegate> protocol.Wineskin
This code is swift 4 for circular transition. https://mcmap.net/q/270822/-circular-transition-mask-in-ios-and-objective-cHamper
W
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.

Wineskin answered 26/10, 2014 at 4:6 Comment(13)
Along with that how to retain the default swipe to back (Screen Edge Gesture)?Kafir
@Kafir - 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.Wineskin
@Wineskin 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?Millenary
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?Merryman
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.Wineskin
@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.Wineskin
hey @Wineskin .. can we do UIPercentDrivenInteractiveTransition with pangesture with pushing instantiateViewController?Barnwell
@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.Wineskin
@Wineskin 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?Independency
@Independency - 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.Wineskin
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.Undercarriage
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.Undercarriage
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().Farrow
M
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.

Miliary answered 10/4, 2016 at 19:39 Comment(2)
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.Kragh
Point being that I am using autolayout and set the frame nowhere else...Kragh
U
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
    //...
}
Utile answered 8/11, 2017 at 12:55 Comment(0)
K
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)
}
Krispin answered 15/2, 2018 at 11:55 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.