自定义转场动画
写在前面
本文中提到的 presented 和 presenting ,分别指被展示者和展示者,譬如 [viewControllerA presentViewController:viewControllerB animated:NO]
中,viewControllerA
是 presenting,viewControllerB
是 presented。
从熟悉的地方开始
苹果提供的几种转场动画,可以用 UIModalPresentationStyle
和 UIModalTransitionStyle
来设置,从 presentation 和 transition 两个关键词的语意来理解转场动画,presentation 重静态的展示,可通过 UIModalPresentationStyle
来定义 presented view
的展示形式(全屏、formsheet
、popover
等);transition 重动态的转场,可通过 UIModalTransitionStyle
来定义从 presenting view
上呈现 presented view
的动画。
UIModalPresentationStyle | Discussion |
---|---|
UIModalPresentationFullScreen | 全屏展示presented view,presenting view 在转场动画完成后被移除 |
UIModalPresentationPageSheet | horizontally compact 场景下同 UIModalPresentationFullScreen;horizontally regular 场景下 presented view 的 width 和 height 等于 presenting view 在 portrait 模式下的 width 与 height;未遮挡部分虚化禁止用户交互 |
UIModalPresentationFormSheet | horizontally compact 场景下同 UIModalPresentationFullScreen; horizontally regular 场景下,presented view 的长宽小于 screen 、居中显示,landscape mode 下presented view 会随着键盘的弹出而上移;未遮挡部分虚华禁止用户交互 |
UIModalPresentationCurrentContext | 转场动画开始前,UIKit 开始从 presenting view controller 向上寻找,presented view controller 的内容将覆盖第一个找到的 definesPresentationContext = YES 的 view controller 的内容;转场动画结束时,被覆盖的内容将被移除 |
UIModalPresentationCustom | 将转场动画交由 view controller 的 transitioningDelegate 对象来管理 |
UIModalPresentationOverFullScreen | presented view 覆盖的 content 不会从 view hierarchy 中移除,未被遮挡的部分对用户可见 |
UIModalPresentationOverCurrentContext | CurrentContext 和 OverFullScreen 的结合 |
UIModalPresentationPopover | horizontally regular 场景下, popover 展示;horizontally compact 场景下,同 FullScreen |
UIModalTransitionStyle | Discussion |
---|---|
UIModalTransitionStyleCoverVertical | 默认值,从屏幕底部上推 |
UIModalTransitionStyleFlipHorizontal | 旋转门 自己体会 |
UIModalTransitionStyleCrossDissolve | 渐入渐出 |
UIModalTransitionStylePartialCurl | 浮夸的翻页 |
(transition style
和 presentation style
由 presented view controller
来设置,这也符合 presented one
不应该依赖 presenting one
的思想。)
如果上述预定义转场动画不能满足你,那么需要实现自定义动画;自定义动画主要通过实现或集成了 UIViewControllerAnimatedTransitioning
协议和 UIPresentationController
类的动画控制器 animator 来实现,稍作了解后,你会发现 UIViewControllerAnimatedTransitioning
和 UIModalTransitionStyle
相似, UIPresentationController
和 UIModalPresentationStyle
相似。
预定义转场动画通过给 UIViewController
的 modalPresentationStyle
和 modalTransitionStyle
属性赋值来实现,而自定义转场动画则须是令 modalPresentationStyle = UIModalPresentationStyleCustom
,并且给 transitioningDelegate
赋值一个继承了 UIViewControllerTransitioningDelegate
的对象 (初次使用时容易把 UIViewControllerAnimatedTransitioning
和 UIViewControllerTransitioningDelegate
弄混,其实二者是截然不同的概念),继而在此对象的协议方法中分配动画控制器。通常我们使用当前的 view controller 来实现 UIViewControllerTransitioningDelegate
协议,具体代码如下:
@interface ViewController () <UIViewControllerTransitioningDelegate>
@end
@implementation ViewController
- (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil
{
if ((self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil]) != nil) {
[self setModalPresentationStyle:UIModalPresentationCustom];
[self setTransitioningDelegate:self];
}
return self;
}
- (id <UIViewControllerAnimatedTransitioning>)animationControllerForPresentedController:(UIViewController *)presented presentingController:(UIViewController *)presenting sourceController:(UIViewController *)source
{
return nil; // return a transition animator
}
- (id <UIViewControllerAnimatedTransitioning>)animationControllerForDismissedController:(UIViewController *)dismissed
{
return nil; // return a transition animator
}
- (UIPresentationController *)presentationControllerForPresentedViewController:(UIViewController *)presented presentingViewController:(UIViewController *)presenting sourceViewController:(UIViewController *)source
{
return nil; // return a presentation animator
}
上述代码中,animationControllerForPresentedController
要求 programer 返回一个 present 当前 ViewController 的动画控制器,animationControllerForDismissController
要求 coder 返回一个 dismiss 当前 view controller
的动画控制器,如何编写动画控制器就是我们要发挥想象力的地方了。
好在苹果给了一个很好用的协议,就是上文中提到的 UIViewControllerAnimatedTransitioning
,它有以下方法:
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext;
- calls when presenting or dismissing a view controller
- configure your custom transition in it
- use view-based animation or core animation as you like
- all animations must take place in view specified by the containerView property of transitionContext
animateTransition:
前三段解释都好理解,第四条提出了一个 containerView
的属性, containerView
是转场动画中涉及的所有 view 的 superview,且 containerView
默认添加了 presenting view controller 的 view ,你要做的事情是将 presented view controller 的 view 也添加到 containerView
上。
(不得不说 containerView
是 UIKit 作的很大的一个改变,在 iOS5 之前,view controller
被苹果封装得“紧密严合”,虽然知道不同 view controller
之前的转场不过是 view
的叠加,但我们不能真的用[presentingViewController.view addSubview:presentedViewController.view]
这样的代码来替代 presentViewController:
方法,而在 animateTransition:
方法中,你可以真切地感受到 view 的 present 与 dismiss 是通过 addSubview:
和 removeFromSuperView
来完成的,不同之处是 containerView
必须是所有 view
的 "container")
- (void)animationEnded:(BOOL)transitionCompleted;
- transitionCompleted = YES if the transition completed successfully and the new view controller is displayed
- transitionCompleted = NO if the transition is canceled and the original view is still visible
- use this method to perform any final cleanup operations required by your transition animator
animationEnded:
类似 completion handler
和 failure handler
- (NSTimeInterval)transitionDuration:(id<UIViewControllerContextTransitioning>)transitionContext;
- return the duration in seconds of your custom transition
- 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
了解了必要的知识以后,开始做真正有意思的事情吧~
(如果你想了解实现思路,可以浏览后文中的 GIF 图和伪代码;如果想要看到具体的实现,可以使用【真·代码阅读术】前往 JYCustomTransition 哦)
仿iPhone相册-点击图片放大的转场动画
photo-compressed.gif深情凝视 iOS 系统自带相册,会发现在照片流里 "点击一张照片放大查看" 的过程,看似是 UIImageView
的 frame
发生了变化,实际上是 UICollectionViewController
切换到了 UIPageViewController
。当然这只是没找到源代码之后基于观察的猜测,两个不同 view controller
切换更符合 Cocoa Frameworks 中一贯的各司其职的原则。
那么,仿制一个苹果系统相册,coder 须要做的是将 presentViewController
的具体过程委托给自定义的第三方(也就是上文提到的动画控制器)来实现,以达到欺骗用户眼球的效果。重点在于我们看到的 “整齐排列的照片中一张照片被放大” 的效果,笔者的做法是创建一个独立于 viewController
的 animateImageView
,设置 animateImageView
的隐式动画来欺骗眼球,动画完成后即 remove,伪代码如下:
- (void)animateTransition:(id <UIViewControllerContextTransitioning>)transitionContext
{
UIImage *image = getImageFromPhotoGraphy();
UIImageView *animateImageView = [[UIImageView alloc] initWithImage:image];
[animateImageView setFrame:getInitialFrameFromPhotoGraphy()];
[containerView addSubview:animateImageView ];
[UIView animateWithDuration:0.5 animations:^{
[animateImageView setFrame:getFinalFrameFromPhotoGraphy()];
} completion: ^(BOOL finished) {
[animateImageView removeFromSuperView]
}]
}
在具体实现的过程中,笔者还考虑了不同图片尺寸的修正来达到视觉上的流畅效果,感兴趣的小伙伴可以前往 JYCustomTransition 查看~
为了欺骗而欺骗-开门/关门的转场动画
首先直接上GIF图吧
door_effect-iloveimg-compressed.gif依然是 “看起来” 是一张图片被切割、被旋转,实际上是
view controller
之间的转场。(笔者在敲代码的时候想了想,非游戏类的 App 应该不会特意做这么花哨并且毫无意义的转场动画,不过最后还是决定做出来,一个是为了向小伙伴们展示 coder 们为了欺骗用户眼球可以做到什么地步,二是为了介绍转场过程中用到的干货 - CATransform 3D,有很多漂亮的转场效果都有小小的用到 3D Animation 哦)
具体的实现过程是用 UIKit 提供的
UIGraphicsGetImageFromCurrentImageContext()
给 presenting view controller
(雪山背景那个)截屏生成 snapshotImage
,通过对snapshotImage
切割生成的 UIImageView
设置 transform
属性来实现旋转开门/关门效果,伪代码如下:
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
UIImage *snapshotImage = getSnapshotImageFromPresentingViewController();
UIImageView *leftImageView = cropImageViewFromLeftSnapshotImage();
[[leftImageView layer] setAnchorPoint:CGPointMake(0.0, 0.5)];
[leftImageView setFrame:leftRect];
[containerView insertSubview:leftImageView aboveSubview:toView];
UIImageView *rightImageView = cropImageViewFromRightSnapshotImage();
[[rightImageView layer] setAnchorPoint:CGPointMake(1.0, 0.5)];
[rightImageView setFrame:rightRect];
[containerView insertSubview:rightImageView aboveSubview:toView];
CATransform3D leftRotateTransform = CATransform3DIdentity;
leftRotateTransform.m34 = 4.5 / -2000;
leftRotateTransform = CATransform3DRotate(leftRotateTransform, 90.0 * M_PI / 180.0f, 0, 1.0, 0);
CATransform3D rightRotateTransform = CATransform3DIdentity;
rightRotateTransform.m34 = 4.5 / -2000;
rightRotateTransform = CATransform3DRotate(rightRotateTransform, -90.0 * M_PI / 180.0f, 0, 1.0, 0);
[UIView animateWithDuration:0.5 delay:0.0 options:UIViewAnimationOptionCurveEaseOut animations:^{
[[leftImageView layer] setTransform:leftRotateTransform];
[[rightImageView layer] setTransform:rightRotateTransform];
} completion:^(BOOL finished) {
[leftImageView removeFromSuperview];
[rightImageView removeFromSuperview];
}];
}
笔者在具体实现过程遇到了多个 layer
相互遮挡的问题,后来发现是由于 [leftImageView layer]
和 [rightImageView layer]
的 z
坐标小于 presented view controller
的 layer
的 z
坐标的缘故,举一反三的小伙伴们可以停下来想想怎么解决这个问题,也可以空降到 JYCustomTransition 去看看具体的解决方法哦
终于用到了UIPresentationController-弹出便笺的转场动画
memo-compressed.gif最后这个看起来最朴素的动画包含了
UIViewControllerAnimatedTransitioning
、UIPresentationController
两大主力, UIViewControllerAnimatedTransitioning
实现转场中的 frame 变化,伪代码如下:
- (void)animateTransition:(id<UIViewControllerContextTransitioning>)transitionContext
{
UIViewController *fromViewController = [transitionContext viewControllerForKey:UITransitionContextFromViewControllerKey];
UIViewController *toViewController = [transitionContext viewControllerForKey:UITransitionContextToViewControllerKey];
UIView *fromView = [transitionContext viewForKey:UITransitionContextFromViewKey];
UIView *toView = [transitionContext viewForKey:UITransitionContextToViewKey];
UIView *containerView = [transitionContext containerView];
const CGRect endFrame = [transitionContext finalFrameForViewController:toViewController];
const CGRect startFrame = CGRectOffset(endFrame, 0.0, CGRectGetHeight(endFrame));
[toView setFrame:startFrame];
[toView setAlpha:0.0f];
[containerView addSubview:toView];
[UIView animateWithDuration:0.2 delay:0.0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn animations:^{
[toView setFrame:endFrame];
[toView setAlpha:1.0];
} completion:^(BOOL finished) {
if ([transitionContext transitionWasCancelled]) {
[toView removeFromSuperview];
}
[transitionContext completeTransition:YES];
}];
}
而展示的效果则是在 UIPresentationController
的子类实现,设置了 presented view controller
中的 view
的 frame
(portrait
模式下固定高度 500,landscape
模式下占据全屏),并为 view
加了一个半透明的背景 backgroundView
,这样在 presented view controller
的初始化方法中就可以放心大胆的使用 [[self view] addSubview:subview]
而不用担心 subview
相对上边界的偏移,伪代码如下:
@implementation _JYMemoViewController_PresentationController
- (instancetype)initWithPresentedViewController:(UIViewController *)presentedViewController presentingViewController:(nullable UIViewController *)presentingViewController
{
if ((self = [super initWithPresentedViewController:presentedViewController presentingViewController:presentingViewController]) != nil) {
_backgroundView = [[UIView alloc] initWithFrame:CGRectZero];
[_backgroundView setUserInteractionEnabled:NO];
[_backgroundView setTranslatesAutoresizingMaskIntoConstraints:NO];
_tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTapGestureRecognizer:)];
[_tapGestureRecognizer setDelegate:self];
}
return self;
}
- (CGRect)frameOfPresentedViewInContainerView
{
CGFloat height = fmin(CGRectGetHeight([[self containerView] frame]), 500.0);
return CGRectMake(0.0, CGRectGetMaxY([[self containerView] frame]) - height, CGRectGetWidth([[self containerView] frame]), height);
}
- (void)presentationTransitionWillBegin
{
[super presentationTransitionWillBegin];
[_backgroundView setBackgroundColor:[UIColor colorWithWhite:0.0 alpha:0.5]];
[_backgroundView setAlpha:0.0];
[[self containerView] addSubview:_backgroundView];
[[[_backgroundView topAnchor] constraintEqualToAnchor:[[self containerView] topAnchor]] setActive:YES];
[[[_backgroundView bottomAnchor] constraintEqualToAnchor:[[self containerView] bottomAnchor]] setActive:YES];
[[[_backgroundView leadingAnchor] constraintEqualToAnchor:[[self containerView] leadingAnchor]] setActive:YES];
[[[_backgroundView trailingAnchor] constraintEqualToAnchor:[[self containerView] trailingAnchor]] setActive:YES];
[[[self presentingViewController] transitionCoordinator] animateAlongsideTransition:^(id <UIViewControllerTransitionCoordinatorContext> context) {
[_backgroundView setAlpha:1.0];
} completion:^(id <UIViewControllerTransitionCoordinatorContext> context) {
if ([context isCancelled]) {
[[[self presentedView] layer] setShadowOpacity:0.0f];
[_backgroundView setAlpha:0.0];
}
}];
}
- (void)presentationTransitionDidEnd:(BOOL)completed
{
if (completed) {
[[self containerView] addGestureRecognizer:_tapGestureRecognizer];
}
[super presentationTransitionDidEnd:completed];
}
- (void)dismissalTransitionWillBegin
{
[super dismissalTransitionWillBegin];
[[self containerView] removeGestureRecognizer:_tapGestureRecognizer];
[[[self presentingViewController] transitionCoordinator] animateAlongsideTransition:^(id <UIViewControllerTransitionCoordinatorContext> context) {
[[[self presentedView] layer] setShadowOpacity:0.0f];
[_backgroundView setAlpha:0.0];
} completion:^(id <UIViewControllerTransitionCoordinatorContext> context) {
if ([context isCancelled]) {
[[[self presentedView] layer] setShadowOpacity:1.0f];
[_backgroundView setAlpha:1.0];
}
else {
[_backgroundView removeFromSuperview];
}
}];
}
- (void)dismissalTransitionDidEnd:(BOOL)completed
{
if (!completed) {
[[self containerView] addGestureRecognizer:_tapGestureRecognizer];
}
[super dismissalTransitionDidEnd:completed];
}
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id <UIViewControllerTransitionCoordinator>)coordinator
{
[super viewWillTransitionToSize:size withTransitionCoordinator:coordinator];
[coordinator animateAlongsideTransition:^(id <UIViewControllerTransitionCoordinatorContext> context) {
[[self presentedView] setFrame:CGRectMake(CGRectGetMinX([[self containerView] frame]), CGRectGetMinY([[self containerView] frame]) + fmax(size.height - 500.0, 0.0), CGRectGetWidth([[self containerView] frame]), CGRectGetHeight([[self containerView] frame]) - fmax(size.height - 500.0, 0.0))];
} completion:nil];
}
- (void)_handleTapGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
{
[[self presentingViewController] dismissViewControllerAnimated:YES completion:nil];
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
{
if (gestureRecognizer == _tapGestureRecognizer) {
if ([touch view] != nil) {
return [touch view] != [self presentedView] && ![[touch view] isDescendantOfView:[self presentedView]];
}
}
return YES;
}
@end
依然可以前往 JYCustomTransition 看到全部代码哦