Custom Push/Pop and Present/Dismiss transitions

created at 10-22-2021 views: 47

Project Overview

The most common animation in iOS is undoubtedly the transition animation of Push and Pop, followed by the transition animation of Present and Dismiss.
If we want to customize these transition animations, Apple actually provides related APIs. Before customizing the transition, we need to understand the transition principle and processing logic.

Push/Pop transition

Push/Pop transition principle

Before calling pushViewController:animated: of the navigation controller, if the delegate object of the navigation controller is set, the callback method navigationController:animationControllerForOperation:fromViewController:toViewController: of the delegate object will be called. You can customize the transition in this callback method. The callback method needs to return an object that complies with the UIViewControllerAnimatedTransitioning protocol, and define a class that implements two methods of the UIViewControllerAnimatedTransitioning protocol in order to customize the Push/Pop transition. The two methods that must be implemented are as follows:

- (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext;
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext;

Use runtime to provide UIViewController with an attribute hr_addTransitionFlag, which is used to mark whether to add a custom transition. code show as below:

@interface UIViewController (TransitionProperty)
@property (nonatomic, assign) BOOL hr_addTransitionFlag;// Whether to add a custom transition
@end

#import "UIViewController+TransitionProperty.h"
#import <objc/runtime.h>

static NSString *hr_addTransitionFlagKey = @"hr_addTransitionFlagKey";
@implementation UIViewController (TransitionProperty)

- (void)setHr_addTransitionFlag:(BOOL)hr_addTransitionFlag {
    objc_setAssociatedObject(self, &hr_addTransitionFlagKey, @(hr_addTransitionFlag), OBJC_ASSOCIATION_ASSIGN);
}
- (BOOL)hr_addTransitionFlag {
    return [objc_getAssociatedObject(self, &hr_addTransitionFlagKey) integerValue] == 0 ?  NO : YES;
}
@end

As mentioned above, as long as the delegate is set for the navigation controller, after calling pushViewController:animated:, the navigationController:animationControllerForOperation:fromViewController:toViewController: method will be executed to display the custom Push/Pop transition, and the same applies after calling popViewControllerAnimated:. The code of the navigation controller is as follows:

-(void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated{
     /*After setting the delegate to the navigation controller, calling pushViewController:animated:,
       Will execute navigationController:animationControllerForOperation:fromViewController:toViewController:
      */
     self.delegate = (id)viewController;
     [super pushViewController:viewController animated:animated];
}

-(UIViewController *)popViewControllerAnimated:(BOOL)animated{
     /*Set the delegate to the navigation controller, call popViewControllerAnimated:,
       Will execute navigationController:animationControllerForOperation:fromViewController:toViewController:
      */
     self.delegate = self.viewControllers.lastObject;
     return [super popViewControllerAnimated:animated];
}

Custom transition

Here is a custom transition where toView moves down from the top of the screen to the center of the screen during Push, and a transition where toView moves down from the center of the screen and out of the screen during Pop. The implementation code is as follows:

#import <UIKit/UIKit.h>
@interface HRPushAnimatedTransitioning : NSObject <UIViewControllerAnimatedTransitioning,CAAnimationDelegate>
@property(nonatomic, assign)UINavigationControllerOperation operation;
@end

@implementation HRPushAnimatedTransitioning
-(NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext{
    return 0.4;
}

-(void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext{
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIView *fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];
    //The containerView of Push/Pop has a subview fromView by default
    UIView *containerView = transitionContext.containerView;
    NSLog(@"Push/Pop containerView: %@", containerView.subviews);
    //ContainerView originally has fromView, just add toView
    [containerView addSubview:toView];

    CGRect fromViewStartFrame = [transitionContext initialFrameForViewController:fromVC];
    CGRect toViewStartFrame = [transitionContext finalFrameForViewController:toVC];

    CGRect fromViewEndFrame = fromViewStartFrame;
    CGRect toViewEndFrame = toViewStartFrame;

    if (_operation == UINavigationControllerOperationPush) {
        toViewStartFrame.origin.y -= toViewEndFrame.size.height;
    }else if (_operation == UINavigationControllerOperationPop) {
        fromViewEndFrame.origin.y += fromViewStartFrame.size.height;
        [containerView sendSubviewToBack:toView];
    }

    fromView.frame = fromViewStartFrame;
    toView.frame = toViewStartFrame;

    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromView.frame = fromViewEndFrame;
        toView.frame = toViewEndFrame;
    } completion:^(BOOL finished) {
        [transitionContext completeTransition:!transitionContext.transitionWasCancelled];
    }];
}

Handling system's right swipe return gesture

Beginning with iOS7, Apple provided a gesture for sliding back to the previous interface. Because I set the delegate of the navigation controller in the pushViewController:animated: method, the right sliding back gesture became invalid. The solution is to reset the delegate object of the right sliding back gesture. :

-(void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    __weak typeof(self) weakself = self;
    if ([self respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        /*As long as you customize the leftBarButtonItem or navigationController of the navigationItem, the sliding gesture will be invalid.
          Therefore, reset the proxy of the right swipe return gesture that comes with the system to self
         */
        self.interactivePopGestureRecognizer.delegate = weakself;
    }
}

After the above settings, the rootViewController will also respond to the right sliding return, which may cause some problems, so the right sliding return function of the rootViewController needs to be prohibited. That is, the code in the navigation controller is as follows:

#pragma mark-UIGestureRecognizerDelegate
-(BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{
     if (gestureRecognizer == self.interactivePopGestureRecognizer) {
         //Shield the sliding return gesture of rootViewController to avoid the crash problem caused by the right sliding return gesture
         if (self.viewControllers.count <= 1 || self.visibleViewController == [self.viewControllers objectAtIndex:0]) {
             return NO;
         }
     }
     return YES;
}

Note that the right swipe return gesture is enabled by default, that is, the enable of self.interactivePopGestureRecognizer is YES by default

Handle the transition of the right swipe back gesture

Although the custom Push/Pop transition is implemented above, our custom Push/Pop transition effect is not displayed when using the system's built-in sliding gesture pop, but the system default transition effect is still displayed.
The reason is that when the transition of Push or Pop is customized, the system calls the navigationController:animationControllerForOperation:fromViewController:toViewController: method. If the method returns a non-nil object, the following proxy method will be executed:

- (id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController

This is the proxy method that Apple provides to developers for custom sliding gesture interaction transitions. It returns an object that complies with the UIViewControllerInteractiveTransitioning protocol. The object needs to implement the startInteractiveTransition: method. For this reason, Apple provides a UIPercentDrivenInteractiveTransition class that implements the protocol. Defining a class that inherits the UIPercentDrivenInteractiveTransition class can satisfy the condition of returning the object, instead of implementing the startInteractiveTransition: method.
Because when the object returned by navigationController:animationControllerForOperation:fromViewController:toViewController is not nil, Push and Pop will both call back the navigationController:interactionControllerForAnimationController: proxy method, and we rewrite the proxy method only for the transition of the right swipe return gesture, and return nil in other cases. Therefore, it is necessary to distinguish between push and pop. The solution is to save the current push or pop in navigationController:animationControllerForOperation:fromViewController:toViewController. code show as below:

// Used to customize the transition of Push or Pop
// If the return value is not nil, it means using a custom Push or Pop transition. nil means to use the system default transition
-(id<UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC{

    if (!self.hr_addTransitionFlag) {
        return nil;
    }
    HRPushAnimatedTransitioning *obj = [[HRPushAnimatedTransitioning alloc] init];
    obj.operation = operation;
    _operation = operation;
    if (operation == UINavigationControllerOperationPush) {
//            NSLog(@"_interactive:%@--%@", _interactive, self);
        if (_interactive == nil) {
            _interactive = [[HRPercentDrivenInteractiveTransition alloc] init];
        }
        [_interactive addGestureToViewController:self];
    }
    return obj;
}

// Use custom Push or Pop transitions to call back this method, which is used to customize the transition interaction mode of sliding gestures
// Use custom Push or Pop transitions to call back this method, which is used to customize the transition interaction mode of sliding gestures
-(id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController{

    if (_operation == UINavigationControllerOperationPush) {
        return nil;
    }else{
        if (_interactive.canInteractive) {
            return  _interactive;
        }else{
            return nil;
        }
    }
}

Realize the transition of a custom right swipe back gesture

The logic of the HRPercentDrivenInteractiveTransition class is: add Pan gestures to the controller view. When sliding to the right, calculate the percentage of the screen width occupied by the right sliding (can be considered as the transition progress parameter), and then call the navigation controller when the right sliding starts popViewControllerAnimated:. Call updateInteractiveTransition: during the sliding process, and pass in the transition progress parameter percent. At the end of the transition, according to the transition progress, judge whether to call finishInteractiveTransition (transition completed, that is, successfully popped to the previous interface) or cancelInteractiveTransition (transition restored to the starting point). The final code is as follows:

#import <UIKit/UIKit.h>
//UIPercentDrivenInteractiveTransition implements the UIViewControllerInteractiveTransitioning protocol
@interface HRPercentDrivenInteractiveTransition: UIPercentDrivenInteractiveTransition

@property (readonly, assign, nonatomic) BOOL canInteractive;
-(void)addGestureToViewController:(UIViewController *)vc;
@end

@interface HRPercentDrivenInteractiveTransition ()
@property (nonatomic, weak) UINavigationController *nav;
@property (nonatomic, strong) CADisplayLink *displayLink;
@property (nonatomic, assign) CGFloat percent;
@end

@implementation HRPercentDrivenInteractiveTransition

-(void)addGestureToViewController:(UIViewController *)vc{
    UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panAction:)];
    [vc.view addGestureRecognizer:pan];
    self.nav = vc.navigationController;
}

-(void)panAction:(UIPanGestureRecognizer *)pan{
    _percent = 0.0;
    CGFloat totalWidth = pan.view.bounds.size.width;

    CGFloat x = [pan translationInView:pan.view].x;
    _percent = x/totalWidth;

    switch (pan.state) {
        case UIGestureRecognizerStateBegan:{
            _canInteractive = YES;
            [_nav popViewControllerAnimated:YES];
        }
            break;
        case UIGestureRecognizerStateChanged:{
            [self updateInteractiveTransition:_percent];
        }
            break;
        case UIGestureRecognizerStateEnded:{
            _canInteractive = NO;
            [self continueAction];
        }
            break;
        default:
            break;
    }
}

-(BOOL)isCanInteractive{
    return _canInteractive;
}

-(void)continueAction{
    if (_displayLink) {
        return;
    }
    _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(UIChange)];
    [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}

-(void)UIChange {
    CGFloat timeDistance = 1.5/60;
    if (_percent> 0.4) {
        _percent += timeDistance;
    }else {
        _percent -= timeDistance;
    }
    [self updateInteractiveTransition:_percent];

    if (_percent >= 1.0) {
        //Transition completed
        [self finishInteractiveTransition];
        [_displayLink invalidate];
        _displayLink = nil;
    }

    if (_percent <= 0.0) {
        //Transition canceled
        [self cancelInteractiveTransition];
        [_displayLink invalidate];
        _displayLink = nil;
    }
}

Present/Dismiss transition

Present/Dismiss transition principle

The controller sets the transitioningDelegate to itself, complies with the UIViewControllerTransitioningDelegate protocol, and implements the present animation method and dismiss animation method of the protocol, namely the following two methods:

-(id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source;
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed;

These two methods need to return an object that complies with the UIViewControllerAnimatedTransitioning protocol, and define a class that implements the two methods of the UIViewControllerAnimatedTransitioning protocol in order to customize the Present/Dismiss transition.
The key codes of the controller are as follows:

- (instancetype)init
{
    self = [super init];
    if (self) {
        self.transitioningDelegate = self;
    }
    return self;
}

//present transition animation (non-interactive)
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source{
    HRPresentAnimatedTransitioning *obj = [[HRPresentAnimatedTransitioning alloc] initType:PictureTransitionPresent];
    return obj;
}

//dismiss transition animation (non-interactive)
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed{
    HRPresentAnimatedTransitioning *obj = [[HRPresentAnimatedTransitioning alloc] initType:PictureTransitionDismiss];
    return obj;
}

Custom transition

Here is a custom transition in which toView moves from the left to the right of the screen to the center of the screen when Present, and when toView moves out of the screen from the center of the screen to the right when dismiss. The implementation code is as follows:

typedef NS_ENUM(NSInteger,PictureTransitionType) {
    PictureTransitionPresent = 0,//show
    PictureTransitionDismiss //disappear
};

@interface HRPresentAnimatedTransitioning : NSObject <UIViewControllerAnimatedTransitioning>
- (instancetype)initType:(PictureTransitionType)type;
@end

#import "HRPresentAnimatedTransitioning.h"
@interface HRPresentAnimatedTransitioning ()
@property(nonatomic, assign)PictureTransitionType type;
@end

@implementation HRPresentAnimatedTransitioning

- (instancetype)initType:(PictureTransitionType)type{
    self = [super init];
    if (self) {
        _type = type;
    }
    return self;
}

-(NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext{
    return 0.4;
}

-(void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext{
    //present时,fromVC是导航控制器,toVC是HRDetailViewController。dismiss时,fromVC是HRDetailViewController,toVC是导航控制器
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIView *fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];
    //The containerView of Present/Dismiss has no subviews by default
    UIView *containerView = transitionContext.containerView;
//    NSLog(@"Present/Dismiss containerView:%@", containerView.subviews);

    CGRect fromViewStartFrame = [transitionContext initialFrameForViewController:fromVC];
    CGRect toViewStartFrame = [transitionContext finalFrameForViewController:toVC];

    CGRect fromViewEndFrame = fromViewStartFrame;
    CGRect toViewEndFrame = toViewStartFrame;

    if (_type == PictureTransitionPresent) {
        [containerView addSubview:toView];
        toViewStartFrame.origin.x -= toViewEndFrame.size.width;
    }else if (_type == PictureTransitionDismiss) {
        fromViewEndFrame.origin.x += fromViewStartFrame.size.width;
    }

    fromView.frame = fromViewStartFrame;
    toView.frame = toViewStartFrame;

    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromView.frame = fromViewEndFrame;
        toView.frame = toViewEndFrame;
    } completion:^(BOOL finished) {
        [transitionContext completeTransition:!transitionContext.transitionWasCancelled];
    }];
}
@end
created at:10-22-2021
edited at: 10-22-2021: