自定義Push/Pop和Present/Dismiss轉場

項目概述

  • iOS中最常見的動畫無疑是Push和Pop的轉場動畫了,其次是Present和Dismiss的轉場動畫。
    如果我們想自定義這些轉場動畫,蘋果其實提供了相關的API,在自定義轉場之前,我們需要了解轉場原理和處理邏輯。下面是自定義轉場的效果:
  • 項目地址:CustomPushAndPresent
    如果文章和項目對你有幫助,還請給個Star⭐️,你的Star⭐️是我持續輸出的動力,謝謝啦😘

Push/Pop轉場

Push/Pop轉場原理

  • 在調用導航控制器的pushViewController:animated:之前,如果設置了導航控制器的delegate對象,就會調用delegate對象的回調方法navigationController:animationControllerForOperation:fromViewController:toViewController:,可在該回調方法中自定義轉場,該回調方法需要返回一個遵守UIViewControllerAnimatedTransitioning協議的對象,定義一個類實現UIViewControllerAnimatedTransitioning協議的兩個方法以便自定義Push/Pop轉場,這兩個必須實現的方法如下:
- (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext;
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext;
  • 用runtime給UIViewController提供一個屬性hr_addTransitionFlag,用於標記是否添加自定義轉場。程式碼如下:
@interface UIViewController (TransitionProperty)
@property (nonatomic, assign) BOOL hr_addTransitionFlag;//是否添加自定義轉場
@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

上面說過只要給導航控制器設置delegate,則調用pushViewController:animated:後,就會執行navigationController:animationControllerForOperation:fromViewController:toViewController:方法,從而展示自定義的Push/Pop轉場,調用popViewControllerAnimated:後同理。導航控制器的程式碼如下:

-(void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated{
    /*給導航控制器設置了delegate,調用pushViewController:animated:後,
      會去執行navigationController:animationControllerForOperation:fromViewController:toViewController:
     */
    self.delegate = (id)viewController;
    [super pushViewController:viewController animated:animated];
}
    
-(UIViewController *)popViewControllerAnimated:(BOOL)animated{
    /*給導航控制器設置了delegate,調用popViewControllerAnimated:後,
      會去執行navigationController:animationControllerForOperation:fromViewController:toViewController:
     */
    self.delegate = self.viewControllers.lastObject;
    return [super popViewControllerAnimated:animated];
}

自定義轉場

  • 這裡自定義一種Push時toView從螢幕頂部往下移動到螢幕中央的轉場,Pop時toView從螢幕中央往下移出螢幕的轉場。實現程式碼如下:
#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];
    //Push/Pop的containerView默認有一個子視圖fromView
    UIView *containerView = transitionContext.containerView;
    NSLog(@"Push/Pop containerView: %@", containerView.subviews);
    //containerView本來有fromView,只需添加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];
    }];
}

處理系統的右滑返回手勢

  • iOS7開始蘋果提供了一個滑動返回上一介面的手勢,由於我在pushViewController:animated:方法中設置了導航控制器的delegate,導致右滑返回手勢失效,解決方式是重新設置右滑返回手勢的delegate對象:
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    __weak typeof(self) weakself = self;
    if ([self respondsToSelector:@selector(interactivePopGestureRecognizer)]) {
        /*只要自定義navigationItem的leftBarButtonItem或navigationController,滑動手勢會失效。
          因此要重新設置系統自帶的右滑返回手勢的代理為self
         */
        self.interactivePopGestureRecognizer.delegate = weakself;
    }
}

以上設置後,rootViewController也會響應右滑返回,可能導致一些問題,因此需要禁止rootViewController的右滑返回功能。即導航控制器中的程式碼如下:

#pragma mark - UIGestureRecognizerDelegate
-(BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{
    if (gestureRecognizer == self.interactivePopGestureRecognizer) {
        //屏蔽rootViewController的滑動返回手勢,避免右滑返回手勢引起死機問題
        if (self.viewControllers.count <= 1 || self.visibleViewController == [self.viewControllers objectAtIndex:0]) {
            return NO;
        }
    }
    return YES;
}

注意右滑返回手勢默認是啟用的,即self.interactivePopGestureRecognizer的enable默認是YES

處理右滑返回手勢的轉場

  • 上面雖然實現了自定義Push/Pop轉場,但是用系統自帶滑動手勢pop時並沒有展示我們自定義的Push/Pop轉場效果,展示的依然是系統默認的轉場效果。
    原因是當自定義了Push or Pop的轉場,系統調用navigationController:animationControllerForOperation:fromViewController:toViewController:方法,該方法如果返回的是非nil對象後,就會執行以下代理方法:
-(id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController

這是蘋果提供給開發者自定義滑動手勢交互轉場的代理方法,返回一個遵守UIViewControllerInteractiveTransitioning協議的對象,該對象需要實現startInteractiveTransition:方法,為此蘋果提供了一個實現該協議的UIPercentDrivenInteractiveTransition類,我們只需定義一個繼承UIPercentDrivenInteractiveTransition類的類,就能滿足返回對象的條件,而不需要是實現startInteractiveTransition:方法。
由於當navigationController:animationControllerForOperation:fromViewController:toViewController返回的對象非nil時,Push和Pop都會回調navigationController:interactionControllerForAnimationController:代理方法,而我們重寫該代理方法只是針對右滑返回手勢的轉場,其他情況返回nil,因此需要區分push還是pop。解決方式是在navigationController:animationControllerForOperation:fromViewController:toViewController中保存當前是push還是pop。程式碼如下:

//用於自定義Push or Pop的轉場
//返回值非nil表示使用自定義的Push or Pop轉場。nil表示使用系統默認的轉場
-(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;
}
    
//使用自定義的Push or Pop轉場才會回調該方法,用於自定義滑動手勢的轉場交互方式
//返回值非nil表示可交互處理轉場進度。nil表示無法交互處理轉場進度,直接完成轉場
-(id<UIViewControllerInteractiveTransitioning>)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id<UIViewControllerAnimatedTransitioning>)animationController{
        
    if (_operation == UINavigationControllerOperationPush) {
        return nil;
    }else{
        if (_interactive.canInteractive) {
            return  _interactive;
        }else{
            return nil;
        }
    }
}

實現自定義右滑返回手勢的轉場

  • HRPercentDrivenInteractiveTransition類的邏輯是:給控制器view添加Pan手勢,當右滑時,計算右滑占螢幕寬度的百分比percent(可認為是轉場進度參數),然後在右滑開始時,調用導航控制器的popViewControllerAnimated:。滑動過程中調用updateInteractiveTransition:,傳入轉場進度參數percent。轉場結束時根據轉場進度,判斷是調用finishInteractiveTransition(轉場完成,即成功pop到上一介面)還是cancelInteractiveTransition(轉場恢復到起點)。最終程式碼如下:
#import <UIKit/UIKit.h>
//UIPercentDrivenInteractiveTransition實現UIViewControllerInteractiveTransitioning協議
@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) {
        //轉場完成
        [self finishInteractiveTransition];
        [_displayLink invalidate];
        _displayLink = nil;
    }
    
    if (_percent <= 0.0) {
        //轉場取消
        [self cancelInteractiveTransition];
        [_displayLink invalidate];
        _displayLink = nil;
    }
}

Present/Dismiss轉場

Present/Dismiss轉場原理

  • 控制器設置transitioningDelegate為自身,遵守UIViewControllerTransitioningDelegate協議,實現協議的present動畫方法和dismiss動畫方法,即如下兩個方法:
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source;
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed;

這兩個方法需要返回一個遵守UIViewControllerAnimatedTransitioning協議的對象,定義一個類實現UIViewControllerAnimatedTransitioning協議的兩個方法以便自定義Present/Dismiss轉場。
控制器關鍵程式碼如下:

- (instancetype)init
{
    self = [super init];
    if (self) {
        self.transitioningDelegate = self;
    }
    return self;
}
    
//present過渡動畫(非交互)
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source{
    HRPresentAnimatedTransitioning *obj = [[HRPresentAnimatedTransitioning alloc] initType:PictureTransitionPresent];
    return obj;
}
    
//dismiss過渡動畫(非交互)
-(id<UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed{
    HRPresentAnimatedTransitioning *obj = [[HRPresentAnimatedTransitioning alloc] initType:PictureTransitionDismiss];
    return obj;
}

自定義轉場

  • 這裡自定義一種Present時toView從螢幕左邊往右移動到螢幕中央的轉場,dismiss時toView從螢幕中央往右移出螢幕的轉場。實現程式碼如下:
typedef NS_ENUM(NSInteger,PictureTransitionType) {
    PictureTransitionPresent = 0,//顯示
    PictureTransitionDismiss //消失
};
    
@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];
    //Present/Dismiss的containerView默認沒有子視圖
    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

參考資料

Tags: