KittenYang

iOS自定义转场详解04——实现3D翻转效果

这是自定义转场系列的第四篇。由于具有一定的连续性,我会忽略一些基础,所以如果你是第一次看这个系列,可以先过目之前的几篇 ——— UIViewControllerTransitioning的用法实现Keynote中的神奇移动效果实现通过圆圈放大缩小的转场动画

老规矩,先端上GIF。

How to work

首先在StoryBoard上拖两个UIViewController。并且在第一个VC上放一个button,使用Action Segue连接到第二个VC。

然后回到代码界面。和以往一样,我们需要创建两个文件:一个用于从第一个VC过渡到第二个VC的动画(如push),另一个这是第二个过渡到第一个VC的动画(如pop)。这里不得不说iOS7中引入的这种解耦合的方式,它的意义在于无论在哪儿需要用到转场动画的地方,直接把这两个文件扔过去就行了。

我们创建两个文件:KYPushTransitionKYPopTransition 。从名字可以看出,后一个是前一个的反转动画。其实,我们完全可以把两个文件写在一起:KYTransition 。因为两个文件的代码结构几乎别无二致,不同的地方也只要用布尔值区分一下就行了。但这里为了让介绍思路清晰,我们把两个动画分开来实现。

首先是KYPushTransition

先继承 UIViewControllerAnimatedTransitioning 协议。实现下面两个方法:

- (NSTimeInterval)transitionDuration:(id <UIViewControllerContextTransitioning>)transitionContext{
    //动画的时间
    return 0.6f;
}

- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext{
    //动画的逻辑
    ...
}

下面具体介绍动画的逻辑。

- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext{


    //1
    FirstViewController *fromVC = (FirstViewController *)[transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
    SecondViewController *toVC = (SecondViewController *)[transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
    UIView *fromView = fromVC.view;
    UIView *toView = toVC.view;

    UIView *containerView = [transitionContext containerView];
    [containerView addSubview:toView];
    [containerView sendSubviewToBack:toView];

    //2
    CATransform3D transform = CATransform3DIdentity;
    transform.m34 = -0.002;
    containerView.layer.sublayerTransform = transform;

    //3
    CGRect initialFrame = [transitionContext initialFrameForViewController:fromVC];
    fromView.frame = initialFrame;
    toView.frame = initialFrame;

    //4
    [self updateAnchorPointAndOffset:CGPointMake(0.0, 0.5) view:fromView];

    //5
    CAGradientLayer *gradient = [CAGradientLayer layer];
    gradient.frame = fromView.bounds;
    gradient.colors = @[(id)[UIColor colorWithWhite:0.0 alpha:0.5].CGColor,
                        (id)[UIColor colorWithWhite:0.0 alpha:0.0].CGColor];
    gradient.startPoint = CGPointMake(0.0, 0.5);
    gradient.endPoint = CGPointMake(0.8, 0.5);
    UIView *shadow = [[UIView alloc]initWithFrame:fromView.bounds];
    shadow.backgroundColor = [UIColor clearColor];
    [shadow.layer insertSublayer:gradient atIndex:1];
    shadow.alpha = 0.0;

    [fromView addSubview:shadow];

    //6
    [UIView animateWithDuration:[self transitionDuration:transitionContext] delay:0.0 options:UIViewAnimationOptionCurveEaseOut animations:^{

        //旋转fromView 90度
        fromView.layer.transform = CATransform3DMakeRotation(-M_PI_2, 0, 1.0, 0);
        shadow.alpha = 1.0;

    } completion:^(BOOL finished) {

        //7    
        fromView.layer.anchorPoint = CGPointMake(0.5, 0.5);
        fromView.layer.position    = CGPointMake(CGRectGetMidX([UIScreen mainScreen].bounds), CGRectGetMidY([UIScreen mainScreen].bounds));
        fromView.layer.transform = CATransform3DIdentity;
        [shadow removeFromSuperview];
        [transitionContext completeTransition:YES];

    }];

}

解释一下。

1)通过上下文transitionContext获得前后两个UIView,这也是发生动画的具体对象。同时还需要获得containerView,这也是动画发生的地方。我们需要把后一个视图添加上去。为了保证后一个视图加上去之后不遮住前一个视图的动画,我们还要把后一个视图放到最后:[containerView sendSubviewToBack:toView];

2)为了保证视图产生3D的效果,我们需要设置layer的仿射变换。关于仿射变化m34的概念,推荐一篇博客:iOS的三维透视投影

3)为fromView、toView设置初始frame。

4)重置锚点。锚点就是视图旋转时候的中心,就是那个不动的点。关于锚点以及position的关系,你可以参考这一篇解释:这将是你最后一次纠结position与anchorPoint!。所以我们在设置了锚点的之后,还需要把layer的position也设置到相应位置:

-(void)updateAnchorPointAndOffset:(CGPoint)anchorPoint view:(UIView *)view{
    view.layer.anchorPoint = anchorPoint;
    view.layer.position    = CGPointMake(0, CGRectGetMidY([UIScreen mainScreen].bounds));
}

方便记忆,你可以理解锚点会吸附到position上。所以光改变锚点不改变position,那么结果就是锚点确实改了,但是position还是在默认的(0.5,0.5),也就是视图中心。就像这样:

5)给fromView增加左深右浅的阴影。并且一开始的透明度为0,随着翻转的角度变大过渡到1。

6)开始动画。这这里,我们让fromView翻转90度 fromView.layer.transform = CATransform3DMakeRotation(-M_PI_2, 0, 1.0, 0);。这里要注意向外90度是-M_PI_2,y为1.0表示绕着y轴旋转。

7)动画结束,我们需要还原锚点的位置、恢复position的位置、恢复layer的transform为CATransform3DIdentity,并且把阴影层移除。由于一开始我没有没有恢复锚点和position的位置,而且一直没找到原因。知道我查看了视图的层级结构才恍然大悟:

可见,使用控制台的”Debug View Hierarchy“ 是多么有用!

当然,还有最重要最关键的一步:[transitionContext completeTransition:YES];。告诉上下文,动画已经完成。如果你这里不这么做,你将无法从后一个视图返回前一个视图。

好了,至此已经完成所有push动画逻辑。实现pop的逻辑基本无二。

KYPopTransition

1、首先就是删除[containerView sendSubviewToBack:toView];,这时我们可不想让动画躲在后面。

2、第二个不同点,需要加上

    //让toView的截图旋转90度
    toView.layer.transform = CATransform3DMakeRotation(-M_PI_2, 0.0, 1.0, 0.0);

因为这时动画的起始应该先保持在90度的位置,然后慢慢过渡到0度。

3、阴影层的需要加在toView上,并且起始透明度应该为1,终止时为0。

How to Use

1、如果你是一个VC present 到另一个VC,那么FirstViewControllerSecondViewController都需要继承协议是UIViewControllerTransitioningDelegate

然后在FirstViewController中设置代理。

-(void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender{

    SecondViewController *secVC = (SecondViewController *)segue.destinationViewController;
    secVC.transitioningDelegate = self;
    [super prepareForSegue:segue sender:sender];   
}

并实现协议的两个方法:分别对应present和dismiss。

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

    KYPushTransition *flip = [KYPushTransition new];

    return flip;
}

- (id <UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed{
    KYPopTransition *flip = [KYPopTransition new];
    return flip;

}

2、如果你用UINavigationController去控制两个VC,此时如果你什么都不做,segue会自动变成标准的导航栏推进的push、pop。要使用我们自定义的动画,需要让FirstViewController继承协议UINavigationControllerDelegate,然后设置代理为自己self.navigationController.delegate = self;

实现UINavigationControllerDelegate中的相关协议方法,只需要一个:

- (id <UIViewControllerAnimatedTransitioning>)navigationController:(UINavigationController *)navigationController
                                   animationControllerForOperation:(UINavigationControllerOperation)operation
                                                fromViewController:(UIViewController *)fromVC
                                                  toViewController:(UIViewController *)toVC{
    if (operation == UINavigationControllerOperationPush) {

        KYPushTransition *flip = [KYPushTransition new];
        return flip;

    }else if (operation == UINavigationControllerOperationPop){

        KYPopTransition *flip = [KYPopTransition new];
        return flip;

    }else{
        return nil;
    }
}

搞定,现在就运行了。


Where to go —— 如何为转场增加手势交互

如果是一个VC present 到另一个VC,那么就要实现UIViewControllerTransitioningDelegate中的两个方法,和UIViewControllerAnimatedTransitioning一样,分别对应 presentdismiss的动画。

- (id <UIViewControllerInteractiveTransitioning>)interactionControllerForPresentation:(id <UIViewControllerAnimatedTransitioning>)animator;

- (id <UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id <UIViewControllerAnimatedTransitioning>)animator;

这时,你可能已经被各种协议代理名字搞晕了,是的,我一开始也晕了,但只要多练几次还是能就熟悉的。

既然我们之前是创建了两个文件继承UIViewControllerAnimatedTransitioning来实现过渡动画,那么是不是也应该继承UIViewControllerInteractiveTransitioning来实现百分比交互动画呢?因为在图中,两者的关系是并列的。是的,你可以这么做。但是苹果提供了一个更好的类 ———— UIPercentDrivenInteractiveTransition

顾名思义,我们就可以猜到这个类就是专门用来实现手势百分比交互的。怎么使用呢?

为了遵循解耦合,我们新建一个UIPercentDrivenInteractiveTransition的子类 —— KYPopInteractiveTransition

创建一个方法-(void)addPopGesture:(UIViewController *)viewController;,用来给目标视图控制器添加一个边缘滑动手势:

-(void)addPopGesture:(UIViewController *)viewController{

    presentedVC = viewController;
    UIScreenEdgePanGestureRecognizer *edgeGes = [[UIScreenEdgePanGestureRecognizer alloc]initWithTarget:self action:@selector(edgeGesPan:)];
    edgeGes.edges = UIRectEdgeLeft;
    [viewController.view addGestureRecognizer:edgeGes];
}

实现相应的手势方法:

-(void)edgeGesPan:(UIScreenEdgePanGestureRecognizer *)edgeGes{

    //1
    CGFloat translation =[edgeGes translationInView:presentedVC.view].x;
    CGFloat percent = translation / (presentedVC.view.bounds.size.width);
    percent = MIN(1.0, MAX(0.0, percent));
    NSLog(@"%f",percent);

    switch (edgeGes.state) {
        case UIGestureRecognizerStateBegan:{

            //2
            self.interacting =  YES;
            [presentedVC dismissViewControllerAnimated:YES completion:nil];
            //如果是navigationController控制,这里应该是[presentedVC.navigationController popViewControllerAnimated:YES];
            break;
        }
        case UIGestureRecognizerStateChanged:{
            //3
            [self updateInteractiveTransition:percent];
            break;
        }

        case UIGestureRecognizerStateEnded:{
            //4
            self.interacting = NO;
            if (percent > 0.5) {
                [self finishInteractiveTransition];
            }else{
                [self cancelInteractiveTransition];
            }
            break;
        }

        default:
            break;
    }
}

1)计算手指在X轴方向上的偏移距离,与屏幕的宽度的之比保存为一个百分比。也就是说,当手指划过屏幕的距离超过屏幕宽度的1/2,那么剩下的动画就自动完成;否则,取消动画。这里用了MINMAX把百分比始终控制在了0~1之间。

2)滑动开始,指定要执行的操作。这里因为没有使用UINavigation控制两个VC,所以是dismissViewControllerAnimated:。如果是用UINavigation去控制的,那么这里相应的应该是navigationController popViewControllerAnimated:self.interacting的作用稍后揭晓。

3)在UIGestureRecognizerStateChanged 调用 [self updateInteractiveTransition:percent]。这里我们把刚才的百分比传了过去,系统就可以通过这个0~1的竖数值实时改变动画的进度。

4)当UIGestureRecognizerStateEnded的时候,我们需要判断此时手指是否划过屏幕大于一半的距离。如果大于一半,告诉系统完成:[self finishInteractiveTransition]; 反之,告诉系统取消操作:[self cancelInteractiveTransition],这时动画也将返回初始位置。

特别注意,当我们使用了手势百分比交互,在相应的动画逻辑KYPopTransition中,把原来的[transitionContext completeTransition:YES] 改成 [transitionContext completeTransition:![transitionContext transitionWasCancelled]]。如果一直是YES的话,当我们手指划过小于屏幕一半,即使系统知道是取消动画,但在上下文中依然是写死的YES。

使用手势百分比交互

FirstViewController中的-(void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender里面,创建一个KYPopInteractiveTransition的实例并把SecondVC传过去:

-(void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender{

    ...
    popInteractive = [KYPopInteractiveTransition new];
    [popInteractive addPopGesture:secVC];
    ...

}

然后实现UIViewControllerTransitioningDelegate里的这个关于手势百分比交互的方法:

- (id <UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id <UIViewControllerAnimatedTransitioning>)animator{
    return popInteractive.interacting ? popInteractive : nil;

}

好了,这里你发现我们使用了popInteractive.interacting来判断,还记得之前买的关子吗?self.interacting的作用就在这里。因为- (id <UIViewControllerInteractiveTransitioning>)interactionControllerForDismissal:(id <UIViewControllerAnimatedTransitioning>)animator 这个方法在 点击dismiss滑动dismiss时候都会调用。然而如果只是return popInteractive;的话,当我们点击dismiss的时候,程序将不会做出反应。所以,我们需要区分,到底是点击dismiss还是滑动dismiss。因此,我们需要一个布尔值来判断,就是这样。

这个系列应该差不多到这里就结束了。本篇的源码你可以在这里获得。


有任何疑问,欢迎在下方评论区域留言:D

KittenYang

写写代码,做做设计,看看产品。