这是自定义转场系列的第四篇。由于具有一定的连续性,我会忽略一些基础,所以如果你是第一次看这个系列,可以先过目之前的几篇 ——— UIViewControllerTransitioning的用法 、实现Keynote中的神奇移动效果、实现通过圆圈放大缩小的转场动画。
老规矩,先端上GIF。
How to work
首先在StoryBoard上拖两个UIViewController。并且在第一个VC上放一个button,使用Action Segue连接到第二个VC。
然后回到代码界面。和以往一样,我们需要创建两个文件:一个用于从第一个VC过渡到第二个VC的动画(如push),另一个这是第二个过渡到第一个VC的动画(如pop)。这里不得不说iOS7中引入的这种解耦合的方式,它的意义在于无论在哪儿需要用到转场动画的地方,直接把这两个文件扔过去就行了。
我们创建两个文件:KYPushTransition
和 KYPopTransition
。从名字可以看出,后一个是前一个的反转动画。其实,我们完全可以把两个文件写在一起: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,那么FirstViewController
和SecondViewController
都需要继承协议是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
一样,分别对应 present
和 dismiss
的动画。
- (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,那么剩下的动画就自动完成;否则,取消动画。这里用了MIN
和MAX
把百分比始终控制在了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