苹果自家应用 Photos 里点击相册后的动画是非常精妙的,而且是可交互的。我有类似的动画需求,上面是我自己的设计效果。本指南分上下两篇,分别探讨非交互和交互动画的实现,从入门到深入,并搜集了实现过程中遇到的一些陷阱,对于想深入的人我想说这两篇文章不会浪费你的时间。
动画分析
如上所示,我希望呈现出打开相簿后照片飞出来的效果,这个设计是行为上的拟物,最好翻开封面时还能发出金光,NO,NO,太浮夸了,简直跟中华小当家或者国产奇幻剧开宝箱似的。当然,主要是我不知道怎么做,会做的话我就会做出来给大家看的,不过,我是不会把这种效果放在正常的产品里的,在游戏界这种效果比较常见,比如炉石里新卡牌点开时就带这种圣光效果。
View Controller Transition 视图控制器转换
自定义 transition 类型
nerror="javascript:errorimg.call(this);">
除了最后一个是布局转换,前三种基本囊括了 iOS 中显示切换视图的全部方式:
2.TabBar Controller 在子视图中切换;
其中 presentations and dismissals 只支持 UIModalPresentationFullScreen 和 UIModalPresentationCustom 这两种 Modal 视图的显示和消失。在 iOS 8 中推出了 UIPresentationController 类对 Modal 视图的显示和消失进行了增强,增加了对自定义 Modal 视图尺寸的支持,自定义 Modal 视图尺寸这在以往是很难做到的(反正我还没有找到好的方法)。
Transition Protocol
nerror="javascript:errorimg.call(this);">
对以上 protocol 的解释节选自 Objc.io 的自定义 ViewController 容器转场:
1.动画控制器 (Animation Controllers) 遵从 UIViewControllerAnimatedTransitioning 协议,并且负责实际执行动画。
3.转场代理 (Transitioning Delegates) 根据不同的转场类型方便的提供需要的动画控制器和交互控制器。
5.转场协调器(Transition Coordinators) 可以在运行转场动画时,并行的运行其他动画。 转场协调器遵从 UIViewControllerTransitionCoordinator 协议。
实战
这篇不涉及交互过程,因此我单独做了个分支:No-Interaction-Transition,是本篇内容的最终版本;或者你还是想自己动手,使用纯色块的 Cell 就好了,几分钟就能搞定,又或者不怕再麻烦一点,提取这个分支里面 Example 文件夹里的文件替换到你的工程好了。到这里还是很简单的,如果觉得不简单,那就看看好了,把本文加入待读列表过一个月后再来学习。
下面需要你配置这样的一个场景,在此基础上逐步改造成最终的效果:在 storyboard 里放置一个UINavigationController和两个UICollectionViewController,如果你不用 storyboard,相信你也能自己搞定设置。
使用场景
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
self.navigationController?.pushViewController(toVC, animated: true)
}
如果你在 storyboard 里通过拉 segue 来完成跳转,那你去- prepareForSegue:sender:里做一些调整了,先别这么干,按照我的节奏来。
第一步,为UINavigationController提供遵守UINavigationControllerDelegate协议的对象(组件3)作为代理 delegate,在 push 和 pop 时系统会要求这个 delegate 来提供动画控制器和交互控制器;没有提供这个代理时,比如上面的情况里,系统将会使用默认的 Slide 动画。该协议的方法名很直白,其中前者必须实现,用于提供组件1来执行实际的动画,后者提供组件2实现交互动画,是可选的。
- navigationController:interactionControllerForAnimationController:
在 storyboard 里拖一个 NSObject 下面图中这一块区域,然后将其类设置为SDENavigationControllerDelegate。你没看错,就是拖一个 NSObject,在你经常拖控件的地方输入 object 就能看到。如果你还不知道,恭喜,现在你又学到新知识了。
在 storyboard 里为 navigation controller 设置 delegate
本文将只实现非交互的动画,可交互的动画在系列下篇讨论。在SDENavigationControllerDelegate类里实现以下方法提供动画控制器:
func navigationController(navigationController: UINavigationController, animationControllerForOperation operation: UINavigationControllerOperation, fromViewController fromVC: UIViewController, toViewController toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
animationController = SDEPushAndPopAnimationController(operation: operation)控制器
}
- transitionDuration: //提供 transition animation 的持续时间
- animationEnded: //可选方法,动画完毕后调用,大部分时候用不上
import UIKit
//通过变量来保存操作类型
init(operation: UINavigationControllerOperation){
super.init
//返回动画执行时间,实际上 navigationBar 的动画时间也由该方法返回的时间决定。
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
return1.0
//执行动画的地方
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
case.Push:
case.Pop:
default:
print("Do Nothing")
}
来看看 WWDC13 Session 218 中对 NavigationController push transition 的解释:
NavigationController Push Transition 图解
图中的两个状态之间的变化就发生在- animateTransition:里,不过动画的执行不限于这里,viewWillXXX, viewDidXXX等这些方法里都可以执行你想要的动画,但是,出于解耦的目的,将所有的动画都放在- animateTransition:里执行,这样就能够也适用于其他UICollectionViewController类了,而如果你需要保证动画执行的顺序,那么这些方法并不是一个好的选择,在 WWDC13 Session 218 里苹果的工程师提到了不能保证viewDidXXX一定在对应的viewWillXXX后面执行,虽然我在三个月前的实现里是依赖这些方法而且没有发现这个问题,那么,可以继续这样做吗?答案是否,使用- animateTransition:可以从根源上杜绝此类问题;不过,所有动画放在这里执行还有一个最最最最最重要的目的,先放结论:你想纳入交互化控制过程的动画必须在- animateTransition:里执行,而且,必须使用 UIView Animation 来实现,不要使用 Core Animation,在系列下篇里实现交互动画时会详细讨论有关细节。科普结束,返回实现过程。
该方法原型为:
该函数的参数由系统提供给我们,同时该参数就是组件4,它提供了 transition 过程中我们需要的绝大部分信息,包括参与 transition 过程的控制器以及 transition 过程的状态,最后还要将 transition 的执行结果通知给系统。
VCTransitionsLibrary 这个库囊括了大部分对 view 整体之间进行切换的效果,而当 transition 涉及 view 上的元素的话,就需要你针对元素进行定制了,这个库就不适用这种情况了。比如神奇移动,就是将 fromView 上的元素移动到 toView 上,实现思路有两种:一是,toView 出现时,将目标元素移动到源元素的位置进行遮挡,然后移动到预定位置,比较简单;二是将 fromView 和 toView 中相同元素都隐藏,对源元素截图并加入 toView 中作为伪装,然后将伪装的源元素移动到 toView 上的指定位置,最后移除伪装的元素然后将目标元素恢复显示。这两个方法中很重要的一点就是无论是伪装的元素还是目标元素在开始和结束移动时的位置和大小都要吻合,不然就露馅了。
动画技术点
上面提到,实现交互动画,一定要使用 UIView Animation 而不是 Core Animation。而且这里的动画还涉及多个元素的配合,不同元素的动画的开始时间与持续时间都不一样,使用 UIView Animation 是没法满足这个要求的,因为常规的延迟执行手段在交互动画里没有作用,只有一个解决办法:UIView key frame animation,这里 push 和 pop 过程中的动画都是采用这种方式实现的。
import UIKit
privatevarcoverRectInSuperviewKey: UInt8 = 1
//保存被选中的封面的索引
get {
returnobjc_getAssociatedObject(self, &selectedIndexPathAssociationKey) as? NSIndexPath
set(newValue) {
objc_setAssociatedObject(self, &selectedIndexPathAssociationKey, newValue, .OBJC_ASSOCIATION_RETAIN)
}
varcoverRectInSuperview: CGRect! {
let value = objc_getAssociatedObject(self, &coverRectInSuperviewKey) as? NSValue
returnvalue?.CGRectValue
set(newValue){
let value = NSValue(CGRect: newValue)
objc_setAssociatedObject(self, &coverRectInSuperviewKey, value, .OBJC_ASSOCIATION_RETAIN)
}
}
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath:NSIndexPath) {
self.selectedIndexPath = indexPath//记录封面索引位置
self.navigationController?.pushViewController(toVC, animated:true)
}
问题1:封面旋转。封面的动画过程本质上和神奇移动有点像,只不过神奇移动里元素在移动,而这里元素位置在原来的位置不动,并且绕左侧旋转。不过,神奇移动之所以为神奇移动在于前后的内容里有相同的元素,但这里并不是,但依然可以采用神奇移动的思路来实现这个效果。由于 toView 里并没有封面这个元素,需要使用伪装的封面,push 时隐藏原封面的同时在 toView 上添加和原封面内容一样的视图来欺骗我们的眼睛,pop 时则将这个伪装封面翻回去,然后恢复源封面的显示。封面的第二个问题,如何保证封面在 toView 上依然保持在视觉正确的位置。这个也好解决,无论当前 collectionView 怎么移动,封面相对于 fromView.superView 和封面相对于 toView.superView 的位置是一样的,因为这两个位置都是相对于当前屏幕的位置。UIView 有一套"convertXXX"的方法用于属于同一个 UIWindow 的视图之间进行坐标的转换:
//对封面进行截图
let snapshotCellView = selectedCell!.snapshotViewAfterScreenUpdates(false)//伪装的封面
//将封面内嵌到一个容器视图里,为什么这么做,这样可以用来解决翻转时背景透明这个问题,具体可以卡片动画这篇博客
coverContainerView.backgroundColor = coverViewBackgroundColor
coverContainerView.tag = 1000
toVC.view.addSubview(coverContainerView)
coverContainerView.frame = toVC.coverRectInSuperview
let frame = coverContainerView.frame
coverContainerView.frame = frame
returncoverContainerView
//配合好封面上翻转和消失动画的时间
func addKeyframeAnimationInPushForFakeCoverView(coverView: UIView?){
UIView.addKeyframeWithRelativeStartTime(0, relativeDuration: 0.5, animations: {
flipLeftTransform.m34 = -1.0 / 500.0
coverView?.layer.transform = flipLeftTransform
//翻转快结束时隐藏
coverView?.alpha = 0
//这一步用于解决翻转时背景是透明的这个问题,当内嵌的内容视图被隐藏时,翻转过程中就能看到背面了。
UIView.addKeyframeWithRelativeStartTime(0.25, relativeDuration: 0.01, animations: {
snapshotView?.alpha = 0
}
问题2:调整 visibleCells,这在 pop 时不是问题,但是在 push 时,你会发现在- animateTransition:里通过 toVC.collectionView?.visibleCells返回的是空数组,没法获取 visibleCells 意味着我们没法对即将出现的 visibleCells 进行调整,怎么办?这个问题在三个月前将我折磨死了,可以从这篇记录里看到当时的历程,由于无法获取 visibleCells 而苦苦寻求其他办法最终却失败。解决办法的关键是从这篇教程How to Create an iOS Book Open Animation里得知的,使用toVC.view.snapshotViewAfterScreenUpdates(true)能够强制视图立即进行刷新,此时可以获取 visibleCells,事实上可以还有方法也可以:- layoutIfNeeded。具体对于这些 visibleCells 根据自身的 indexPath 来设置大小和位置是一件比较繁琐的事情,这部分代码放在setupVisibleCellsBeforePushToVC:里了,这里不详细讨论。
let collectionView = toVC.collectionView!
//不同位置的 cell 的动画的开始时间和持续时间有些许差别,让离得中心越远的元素越早到达位置,最后的效果非常赏心悦目。这个是从上面那个库里学来的,但目前还有点瑕疵。
varrelativeDuration = ......
UIView.addKeyframeWithRelativeStartTime(0, relativeDuration: 0.7, animations: {
cell.alpha = 1
//在封面完全翻开后才开始照片的动画,开始时间各有差异。
cell.transform = CGAffineTransformScale(CGAffineTransformIdentity, 1, 1)
UIView.addKeyframeWithRelativeStartTime(0.5 + relativeStartTime, relativeDuration: relativeDuration, animations: {
cell.center = layoutAttributes!.center
})
}
问题3:调整视图背景色。这是个很不起眼的小地方,但可能会让你栽个大跟头。如果你设置了 toVC 的视图的背景色,动画开始时屏幕就会呈现该背景,这时候 fromView 就立刻不可见了,动画效果是非常糟糕的;这时候你或许会在 storyboard 里将 toVC 的 collectionView 的背景色调整为透明色来解决这个问题,可惜在动画结束后,背景色突然变黑,这是因为动画结束后,fromView 被移除出去了, toView 没有了背景空无一物,屏幕背景自然就变成黑色了。解决办法是,在 storyboard 里将 toVC 的 collectionView 的背景色设置为透明色,然后在 transition 过程中使用动画来进行过渡到你需要的背景色。
UIView.addKeyframeWithRelativeStartTime(0, relativeDuration: 1.0, animations: {
toVC.collectionView?.backgroundColor = toCollectionViewBackgroundColor
}
在实际的代码里我添加了一些属性由于定制动画的某些部分,比如设定封面后面的照片的分布区别,布局的间隔,等等,好像用处不大,随我开心就好。
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
let containerView = transitionContext.containerView
let toVC = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as? UICollectionViewController
let toView = transitionContext.viewForKey(UITransitionContextToViewKey)
let duration = transitionDuration(transitionContext)//这是要求实现的另外一个方法,往回看
case.Push:
//隐藏被选中的封面,同时添加伪装的封面到 toView 里
selectedCell?.hidden = true
let layoutAttributes = fromVC!.collectionView?.layoutAttributesForItemAtIndexPath(fromVC!.selectedIndexPath)
toVC!.coverRectInSuperview = areaRect!
//强制刷新 toView,以便能够在 toVC 的collectionView 被显示之前能够获取 visibleCells。
//针对 visibleCells 调整大小和位置,以便能够隐藏在封面后面,此处比较繁琐,想知道具体实现的话可以看源码
//添加 toView, toView 将会出现在屏幕上
UIView.setAnimationCurve(UIViewAnimationCurve.EaseOut)
//key frame animation 里添加的动画的时间都是针对 duration 进行比例计算的,开始时间和持续时间的值都在0和1之间。
//将上面实现的多步动画添加到这里
self.addKeyframeAnimationInPushForFakeCoverView(self.fakeCoverView)
}, completion: { finished in
//如果 push 被取消,则将一切恢复原样,恢复原装封面的显示
selectedCell?.hidden = false
transitionContext.completeTransition(!isCancelled)
case.Pop:
default:
print("No Operation")
}
实现 Pop
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
case.Push:
case.Pop:
//需要注意的是,此时不能再简单地使用addSubview:,不然 fromView 会被挡住不可见
//根据 tag 来获取伪装的封面
UIView.setAnimationCurve(UIViewAnimationCurve.EaseInOut)
//pop 过程的动画基本上是对 push 过程中动画的逆向。唯一需要注意的是,push 和 pop 时的 visibleCells 可能会不同,需要做出调整,具体看代码
self.addKeyframeAnimationInPopForFakeCoverView(coverView)
}, completion: { finished in
//只有 pop 过程完成了,才能恢复源封面的显示
let selectedCell = toVC?.collectionView?.cellForItemAtIndexPath(toVC!.selectedIndexPath)
selectedCell?.hidden = false
transitionContext.completeTransition(!isCancelled)
})
}
说点什么
参考资料:
微信号:CocoaChinabbs
--------------------------------------
投稿邮箱:support@cocoachina.com
