[iOS] 转场动画

这个topic其实起源于看到了我们别的组的一个小效果,就是首页有一个小标签,标签点开会以圆形扩散的效果打开一个付费页~ 类似酱紫:

转场动画.gif (图片借鉴View-Controller-Transition-PartIII里的)

页面的切换效果,其实就是转场动画。

官方支持以下几种方式的自定义转场:

  1. UINavigationController 中 push 和 pop
  2. UITabBarController 中切换 Tab
  3. presentViewController以及dismiss(modal模态转场)
  4. UICollectionViewController 的布局转场:UICollectionViewController 与 UINavigationController 结合的转场方式 (https://github.com/seedante/iOS-Note/wiki/View-Controller-Transition-PartIII#Chapter4)

我之前做过一个效果就是切换tab的时候from和to有一个渐隐和渐现的效果,并且如果切换方向不同,from和to移动方向也不一样,其实就是利用了转场动画。


1. 自定义转场动画

自定义转场其实就是通过UIViewControllerAnimatedTransitioning协议实现的,所以我们需要先建一个实现了UIViewControllerAnimatedTransitioning的NSObject:

#import "TransAnimation.h"
@import UIKit;

@interface TransAnimation ()

@end

@implementation TransAnimation

- (void)animateTransition:(nonnull id)transitionContext {
}

- (NSTimeInterval)transitionDuration:(nullable id)transitionContext {
    return 0.5;
}

这里的transitionDuration就是把转场动画的时间返回,然后animateTransition就是真的做动画。

如果像上面那样把animateTransition写成一个空block,那么当你点击跳转另一个VC的时候会发现木有反应哦,因为你并木有实现转场所以VC不会显示

那么要怎么转呢,例如酱紫:

- (void)animateTransition:(nonnull id)transitionContext {
    // 获取fromVc和toVc
    UIViewController *fromVc = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toVc = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];

    UIView *fromView = [[UIView alloc] init];;
    UIView *toView = [[UIView alloc] init];

    if ([transitionContext respondsToSelector:@selector(viewForKey:)]) {
        // fromVc 的view
        fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
        // toVc的view
        toView = [transitionContext viewForKey:UITransitionContextToViewKey];
    } else {
        // fromVc 的view
        fromView = fromVc.view;
        // toVc的view
        toView =toVc.view;
    }

    CGFloat x = [UIScreen mainScreen].bounds.size.width;
    
    // 转场环境
    UIView *containView = [transitionContext containerView];
    toView.frame = CGRectMake(-x, 0, containView.frame.size.width, containView.frame.size.height);

    [containView addSubview:fromView];
    [containView addSubview:toView];

    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromView.transform = CGAffineTransformTranslate(fromView.transform, x, 0);
        toView.transform = CGAffineTransformTranslate(toView.transform, x, 0);
    } completion:^(BOOL finished) {
        [transitionContext completeTransition:YES];
    }];
}

那么如果animateTransition里实际做动画的时长与transitionDuration返回的时间不一致会怎样呢?是会按照animateTransition里面的把动画乖乖做完的~ 不会到了animateTransition就把动画掐掉哒
苹果大大告诉我们:

UIKit calls this method to obtain the timing information for your animations. The value you provide should be the same value that you use when configuring the animations in your animateTransition method. UIKit uses the value to synchronize the actions of other objects that might be involved in the transition. For example, a navigation controller uses the value to synchronize changes to the navigation bar.

When determining the value to return, assume there will be no user interaction during the transition—even if you plan to support user interactions at runtime.

来划重点啦~

  • animateTransition里实际做动画的时长与transitionDuration返回的时间必须一致,否则可能其他参与转场的组件例如navigation bar在动画没做完或者已经做完很久了才显示
  • transitionDuration这个时间不需要考虑交互转场的影响,按照非交互即可,即使你真的要用交互转场

另外一个关于animateTransition的注释是酱紫的:

// This method can only  be a nop if the transition is interactive and not a percentDriven interactive transition.
- (void)animateTransition:(id )transitionContext;
  • 也就是说animateTransition只有在是交互式并且不是百分比交互式的情况下才可以是空哦

  • 另外一个注意点是,如果你在A push B的时候加了透明度变化的A渐隐,B渐现的动画,在动画结束需要让A的透明度置位1,否则用户从B pop回A的时候就是黑色没有页面啦:

[UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
  fromView.alpha = 0;
  toView.alpha = 1;
} completion:^(BOOL finished) {
  [transitionContext completeTransition:YES];
  fromView.alpha = 1;
}];

※ 圆形扩散转场

很多很炫的转场都是通过截屏实现的,比如开头的一个圆形扩散,就是把toView截屏,然后以圆形的masklayer逐渐显示。划重点,用layer.mask实现遮罩,外加CAShapeLayer可以做path动画

[fromView snapshotViewAfterScreenUpdates:NO];
[toView snapshotViewAfterScreenUpdates:YES];

注意snapshotViewAfterScreenUpdates的参数就是是不是要立刻截屏,如果yes一般是给fromView的因为转场的时候它就显示着,而对toView而言需要先add然后截屏,所以都选NO。

我们有些开门的动效,就是利用把fromView截图,然后生成两个UIView半屏,把全屏截图放进去作为子view,分别clipToBounds,就得到了两边两个view,然后做位移动画即可~

而有些放大缩小的例如点一个图片全屏就是把toView截屏做放大缩小动画,和圆形扩散类似只是换为了位移大小变化动画。

这里提供举个定点圆形扩散的转场animator:

// .h
typedef NS_ENUM(NSUInteger, TransitionType) {
    TransitionTypePresent = 0, //管理present动画
    TransitionTypeDissmis,
};

@interface TransAnimation : NSObject

@property(nonatomic) TransitionType transitionType;

@end

// .m
#import "TransAnimation.h"

@interface TransAnimation ()

@property (nonatomic) id transitionContext;

@end

@implementation TransAnimation

- (NSTimeInterval)transitionDuration:(nullable id)transitionContext {
    return 3;
}

- (void)animateTransition:
(id)transitionContext {
    if (self.transitionType == TransitionTypePresent) {
        [self presentAnimation:transitionContext];
    } else if (self.transitionType == TransitionTypeDissmis) {
        [self dismissAnimation:transitionContext];
    }
}

- (void)presentAnimation:
(id)transitionContext {
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *tempView = [toVC.view snapshotViewAfterScreenUpdates:YES];
    UIView *containerView = [transitionContext containerView];
    
    [containerView addSubview:toVC.view];
    [containerView addSubview:fromVC.view];
    [containerView addSubview:tempView];
    
    CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
    CGFloat screenHeight = [UIScreen mainScreen].bounds.size.height;
    
    
    CGRect rect = CGRectMake(50, [UIScreen mainScreen].bounds.size.height - 50, 2, 2);
    
    UIBezierPath *startPath = [UIBezierPath bezierPathWithOvalInRect:rect];
    UIBezierPath *endPath = [UIBezierPath bezierPathWithArcCenter:containerView.center radius:sqrt(screenHeight * screenHeight + screenWidth * screenWidth)  startAngle:0 endAngle:M_PI*2 clockwise:YES];
    
    CAShapeLayer *maskLayer = [CAShapeLayer layer];
    maskLayer.path = endPath.CGPath;
    tempView.layer.mask = maskLayer;
    
    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"path"];
    animation.delegate = self;
    
    animation.fromValue = (__bridge id)(startPath.CGPath);
    animation.toValue = (__bridge id)((endPath.CGPath));
    animation.duration = [self transitionDuration:transitionContext];
    animation.timingFunction = [CAMediaTimingFunction  functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    [maskLayer addAnimation:animation forKey:@"PointNextPath"];
    self.transitionContext = transitionContext;
}

- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag {
    UIView *containerView = [self.transitionContext containerView];
    [containerView.subviews.lastObject removeFromSuperview];
    [self.transitionContext completeTransition:YES];
}

- (void)dismissAnimation:
(id)transitionContext {
    UIViewController *fromVC = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    
    UIViewController *toVC = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *tempView = [fromVC.view snapshotViewAfterScreenUpdates:YES];
    UIView *containerView = [transitionContext containerView];
    
    [containerView addSubview:toVC.view];
    [containerView addSubview:tempView];
    
    CGFloat screenWidth = [UIScreen mainScreen].bounds.size.width;
    CGFloat screenHeight = [UIScreen mainScreen].bounds.size.height;
    
    
    CGRect rect = CGRectMake(50, [UIScreen mainScreen].bounds.size.height - 50, 2, 2);
    
    UIBezierPath *endPath = [UIBezierPath bezierPathWithOvalInRect:rect];
    UIBezierPath *startPath = [UIBezierPath bezierPathWithArcCenter:containerView.center radius:sqrt(screenHeight * screenHeight + screenWidth * screenWidth)  startAngle:0 endAngle:M_PI*2 clockwise:YES];
    
    CAShapeLayer *maskLayer = [CAShapeLayer layer];
    maskLayer.path = endPath.CGPath;
    tempView.layer.mask = maskLayer;
    
    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"path"];
    animation.delegate = self;
    
    animation.fromValue = (__bridge id)(startPath.CGPath);
    animation.toValue = (__bridge id)((endPath.CGPath));
    animation.duration = [self transitionDuration:transitionContext];
    animation.timingFunction = [CAMediaTimingFunction  functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
    [maskLayer addAnimation:animation forKey:@"PointNextPath"];
    self.transitionContext = transitionContext;
}

@end

效果就是酱紫的:


圆形扩散收缩

2. 设置动画

现在我们有了动画,要怎么设置给navigation或者present作为效果呢?先看navigation的~

2.1 UINavigationController 中 push 和 pop

可参考:https://blog.csdn.net/dolacmeng/article/details/51873395?utm_source=blogxgwz0

  • 首先我们需要设置navigationController的delegate,这个delegate可以指定动画是啥
@interface MainViewController ()
@end

@implementation MainViewController

- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
    self.navigationController.delegate = self;
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    if (self.navigationController.delegate == self) {
        self.navigationController.delegate = nil;
    }
}

@end
  • 指定动画是啥的方式,这里指定为[[TransAnimation alloc] init]
// MARK: - UINavigationControllerDelegate
- (id)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC {
    if (fromVC == self && [toVC isKindOfClass:[MasViewController class]]) {
        return [[TransAnimation alloc] init];
    }
    
    return nil;
}

注意哦,这里其实可以通过fromVCtoVC判断是push还是pop哈

  • 最后就是只要你让TransAnimation实现了UIViewControllerAnimatedTransitioning即可啦~

2.2 UITabBarController 中切换 Tab

可参考:https://blog.csdn.net/qq_25639809/article/details/61198894

实现UITabBarControllerDelegateanimationControllerForTransitionFromViewController把动画返回回去就好啦~~

@interface DMMainViewController ()

@end

@implementation DMMainViewController

// 实现协议
- (nullable id )tabBarController:(UITabBarController *)tabBarController animationControllerForTransitionFromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC{
    return [[AnimationManager alloc] init];
}

@end

这里一样的可以通过fromVC和toVC判断方向~


2.3 presentViewController以及dismiss

可参考:https://www.jianshu.com/p/15355cc8e133

和nav以及tab非常类似,也是通过delegate来设置动画,区别大概只是delegate里面会分是present还是dismiss,不用你自己判断。

例如你需要从A跳到B,那么你需要做这样的事情:

// VC A
ViewControllerB * bVC = [[ViewControllerB alloc] init];
bVC.transitioningDelegate = self;
[self presentViewController: bVC animated:YES completion:nil];

注意这里把B的transitioningDelegate设为了A了哦

这里A需要实现的delegate就是UIViewControllerTransitioningDelegate

//指定present动画
- (nullable id )animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source;
//指定dismiss动画
- (nullable id )animationControllerForDismissedController:(UIViewController *)dismissed;
//指定交互式present动画的控制类
- (nullable id )interactionControllerForPresentation:(id )animator;
//指定交互式dismiss动画的控制类
- (nullable id )interactionControllerForDismissal:(id )animator;

所以这里举例如果我们想让A跳到B的时候有动画只要酱紫就好啦:

- (id)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source{
    return [[TransAnimation alloc] init];
}

注意如果是present的动画,就在present的时候改delegate,然后实现animationControllerForPresentedController,如果想改dismiss的动画,就确认当前VC的delegate的dismiss是实现了的

例如当前页面显示的时候将delegate设为自己:

- (void)viewDidAppear:(BOOL)animated{
    [super viewDidAppear:animated];
    self.transitioningDelegate = self;
}

- (id)animationControllerForDismissedController:(UIViewController *)dismissed{
    return [[TransAnimation alloc] init];
}

2.4 UICollectionViewController 的布局转场

这个我本来想找个OC的,结果真的木有。。就还是swift的参考啦其实都差不多。。https://github.com/seedante/iOS-Note/wiki/View-Controller-Transition-PartIII#Chapter4

从iOS7开始,在collectionViewController中就伴随着自定义转场的功能产生了一个新的属性:useLayoutToLayoutNavigationTransitions,这是一个BOOL值,如果设置该值为YES,如果navigationController push或者pop 一个collectionViewController 到另一个collectionViewController的时候,其所在的navigationController就可以用collectionView的布局转场动画来替换标准的转场,这点大家可以自行尝试一下,但是显然,这个属性的致命的局限性就是你得必须满足都是collectionViewController,对于collectionView就没办法了。

这个的效果其实就类似我们的照片app里面那种,从一个collection到一个新的collection的平滑过渡,而且只要打开一个熟悉超厉害~

demo引用别人的哈,实在懒得写了sorry:https://github.com/seedante/iOS-ViewController-Transition-Demo/tree/master/CollectionViewControllerLayoutTransition
(注意在这个demo有点问题,没有给collectionView register cell,需要改一下哈要不会在false的时候crash)

左侧是useLayoutToLayoutNavigationTransitions为true,右侧是false.gif

注意需要在pop或者push或者present或者dismiss之前设置delegate哦,否则是不能触发animation哒


3. 可交互转场动画

什么叫可交互转场动画呢?就是根据你的手指位置来控制转场动画进度,类似酱紫:

可交互转场动画

所以要怎么实现呢?

① 首先得准备一个animation,和Part1一样,需要实现UIViewControllerAnimatedTransitioning作为转场动画
// .h
@interface TransAnimation : NSObject

@end

// .m
@interface TransAnimation ()

@end

@implementation TransAnimation

- (void)animateTransition:(nonnull id)transitionContext {
    // 获取fromVc和toVc
    UIViewController *fromVc = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    UIViewController *toVc = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];

    UIView *fromView = [[UIView alloc] init];;
    UIView *toView = [[UIView alloc] init];

    if ([transitionContext respondsToSelector:@selector(viewForKey:)]) {
        // fromVc 的view
        fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
        // toVc的view
        toView = [transitionContext viewForKey:UITransitionContextToViewKey];
    } else {
        // fromVc 的view
        fromView = fromVc.view;
        // toVc的view
        toView =toVc.view;
    }
    
    // 转场环境
    UIView *containView = [transitionContext containerView];
    toView.alpha = 0;

    [containView addSubview:fromView];
    [containView addSubview:toView];

    [UIView animateWithDuration:[self transitionDuration:transitionContext] animations:^{
        fromView.alpha = 0;
        toView.alpha = 1;
    } completion:^(BOOL finished) {
        // 由于是交互的,所以completeTransition不一定会success
        [transitionContext completeTransition:![transitionContext transitionWasCancelled]];
        if ([transitionContext transitionWasCancelled]) {
            //手势取消了
        }else{
            //手势成功
        }
        fromView.alpha = 1;
    }];
}

- (NSTimeInterval)transitionDuration:(nullable id)transitionContext {
    return 0.5;
}

@end

交互式转场的原理就是,更新转场百分比(UIPercentDrivenInteractiveTransition对象),会自动根据百分比算出animateTransition里面的动画进行到什么状态比如alpha、frame之类的然后显示出来。

也就是交互式的转场也是依赖这个animator,只不过附加的百分比计算动画进度并更新,不是让动画自己动。(自己动就是定时器模式啦)

这里例子里是一个渐变的animator,需要注意的点是之前非交互的animator是动画做完转场就一定完成了,但是交互式是不一样的。交互式的松手的时候无论是成功转场还是失败,都会执行动画的completion,所以在里面需要判断[transitionContext transitionWasCancelled]是不是转场取消了

一个疑惑点木有想明白,我们在animateTransition里面写什么动画或者不写动画都是未知的,系统是怎么检测到我们写的动画并在传入百分比的时候按照百分比计算显示的呢?但毕竟正常做动画也是系统算的,只是加一个百分比也很容易对于系统而言

② 设置interactionController

可交互转场动画依赖于一个百分比管理器,也就是interactionController,这个管理器需要实现UIViewControllerInteractiveTransitioning协议,官方给我们提供了一个现成的UIPercentDrivenInteractiveTransition类,你也可以继承UIPercentDrivenInteractiveTransition来使用。

UIViewControllerInteractiveTransitioning协议的功能主要是控制转场动画的状态,即动画完成的百分比,所以只有在转场中才有用。

比如我们通过[self.navigationController popViewControllerAnimated:YES]触发pop转场动画,然后在转场动画结束之前通过- (void)updateInteractiveTransition:(CGFloat)percentComplete更改转场动画的完成的百分比,那么转场动画将由实现UIViewControllerInteractiveTransitioning的类接管,而不是由定时器管理,之后就可以随意设置动画状态了。

交互动画往往配合手势操作,手势操作产生一序列百分比数通过updateInteractiveTransition方法实时更新转场动画状态。

  • 这一步要注意的是,对于navigation的push/pop或者是presentVC和dismiss,设置interactionController的方式都是不一样的哦!也要记得把navigationController.delegate设置上哦,可以在push的时候设置

举个例子:

// dismiss
-(id)interactionControllerForDismissal:(id)animator;

// pop push
- (id)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id)animationController;

这些方法对应不同的触发条件,[self dismissViewControllerAnimated:YES completion:nil]触发的是上面的,[self.navigationController popViewControllerAnimated:YES];触发的是下面的~

这类方法返回的都是一个id对象,也就是我们的百分比控制器。

例如:

@property (nonatomic, strong) UIPercentDrivenInteractiveTransition *interactiveTransition;

self.interactiveTransition = [UIPercentDrivenInteractiveTransition new];

// MARK: - UINavigationControllerDelegate
- (id)navigationController:(UINavigationController *)navigationController interactionControllerForAnimationController:(id)animationController {
    return self.interactiveTransition;
}

即使是可交互转场动画也是动画,需要在delegate返回转场动画的方法里面返回我们①里面创建的animator哦

例如navigation的:

- (id)navigationController:(UINavigationController *)navigationController animationControllerForOperation:(UINavigationControllerOperation)operation fromViewController:(UIViewController *)fromVC toViewController:(UIViewController *)toVC {
    if (operation == UINavigationControllerOperationPop) {
        return [[TransAnimation alloc] init];
    }
    
    return nil;
}
③ 添加手势更新百分比

现在有了百分比控制器,那么怎么更新转场进行到了百分之几呢,这就依赖于手势啦~

UIPanGestureRecognizer * pan = [[UIPanGestureRecognizer alloc] init];
[pan addTarget:self action:@selector(panGestureRecognizerAction:)];
[self.view addGestureRecognizer:pan];

- (void)panGestureRecognizerAction:(UIPanGestureRecognizer *)pan{
   //产生百分比
   CGFloat process = [pan translationInView:self.view].x / ([UIScreen mainScreen].bounds.size.width);
   
   process = MIN(1.0,(MAX(0.0, process)));
   
   if (pan.state == UIGestureRecognizerStateBegan) {
       self.interactiveTransition = [UIPercentDrivenInteractiveTransition new];
       // 注意这里一定要用pop才能触发,因为delegate实现的是navigation的`interactionControllerForAnimationController`
       [self.navigationController  popViewControllerAnimated:YES];
   }else if (pan.state == UIGestureRecognizerStateChanged){
       [self.interactiveTransition updateInteractiveTransition:process];
   }else if (pan.state == UIGestureRecognizerStateEnded
             || pan.state == UIGestureRecognizerStateCancelled){
       if (process > 0.5) {
           [ self.interactiveTransition finishInteractiveTransition];
       }else{
           [ self.interactiveTransition cancelInteractiveTransition];
       }
       self.interactiveTransition = nil;
   }
}

UIGestureRecognizerStateBegan的时候需要触发动画,所以需要用popViewControllerAnimated,并创建UIPercentDrivenInteractiveTransition

然后在UIGestureRecognizerStateChanged的时候更新当前百分比控制器的百分比updateInteractiveTransition

UIGestureRecognizerStateEnded的时候选择是finish还是cancelinteractiveTransition,如果cancel的话就说明转场失败~ finish就是成功哦

到这里就可以实现渐变的交互转场啦撒花花~~~


引用的别人的图说明一下转场的询问顺序:

pop push的时候的动画询问流程

也就是如果有自定义动画并没有交互动画就会把动画交给定时器,按照自定义动画执行~ 如果有就会按照百分比执行。

这里也说明了一定要先实现转场animationControllerForOperation的delegate才会有可交互的转场一说,毕竟可交互的转场只是在原来转场的基础上增加了百分比控制器interactiveTransition

补充一个坑:https://stackoverflow.com/questions/25488267/custom-transition-animation-not-calling-vc-lifecycle-methods-on-dismiss

Reference:

  1. 超全的一个文~ 很喜欢的:https://www.jianshu.com/p/ec08f43808aa
  2. https://www.jianshu.com/p/a9b1307b305b
  3. https://www.jianshu.com/p/cca1dcb79ddf
  4. https://www.jianshu.com/p/29b0165de712?from=groupmessage
  5. 动画合集:https://www.jianshu.com/p/fd3154946919
  6. tableview push collectionViewController:https://www.jianshu.com/p/c609ebc6a433

你可能感兴趣的:([iOS] 转场动画)