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

奔跑的鴻發表於2021-10-22

專案概述

  • iOS中最常見的動畫無疑是Push和Pop的轉場動畫了,其次是Present和Dismiss的轉場動畫。
    如果我們想自定義這些轉場動畫,蘋果其實提供了相關的API,在自定義轉場之前,我們需要了解轉場原理和處理邏輯。下面是自定義轉場的效果:
自定義Push/Pop和Present/Dismiss轉場
  • 專案地址: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

參考資料

相關文章