原文刊载在:jmStack的个人主页 转载请说明出处
大家在平常用微信,微博的过程中肯定(对,就是肯定)都有查看过朋友圈和微博所发布的照片,当点击九宫格的某一图片时图片会慢慢的放大并进入全屏,左右滑动查看另一张.轻点图片又会以动画的方式慢慢缩小回到滑动之后对应的图片.说了这么多估计你还是不知道我在讲什么鬼,一张动图胜过千言万语.毕竟语言这东西真不是码农的特长...
上面两张gif点开时的动画不是很明显,你可以在真机上查看更真实效果.接下来我会通过一个Demo来介绍实现这种效果的具体思路,如果你有更好的思路,请求赐教
Demo 预览
在开始之前先看一看最终的效果
这个Demo抓取了美丽说的在线图片,这里对毫不知情的美丽说表示感谢.
在看下面的部分之前假定你已经撑握了Swift,网络请求,会使用UICollectionView等基础组件的技能.如若不能撑握建议先了解相关知识
DemoGitHub地址
Demo 结构分析
在Demo中主要包括两个主要的视图结构:一 缩略图(主视图)的浏览 二 大图的浏览. 这两个视图中所要展示的内容都是有规律的矩形所以都可以用UICollectionView来实现.
两者的区别在于缩略图是垂直方向的布局而大图是水平方向上的布局方式.两个UICollectionView的cell的内容只包含一个UIImageView.在大图浏览视图中有一个
需要注意的细节:为了图片浏览的效果每张图片之间是有一定间隔的,如果让每个cell都填充整个屏幕,图片的宽度等于cell的宽度再去设置cell的间隔来达到间隔的效果会在停止滑动图片时黑色的间隔会显现在屏幕中(如下图),这并不是我们想看到的结果.
出现这个问题的原因是UICollectionView的分页(pagingEnabled)效果是以UICollectionView的宽来滚动的,也就是说不管你的cell有多大每次滚动总是一个UICollectionView自身的宽.要实现这个效果有个小技巧,相关内容会在大图浏览的实现一节中介绍.
主视图图片浏览的实现
根据上一节得出的结论,主视图采用colletionview,这部分实现没什么特别的技巧,但在添加collectionview之前需要添加几个基础组件.
因为我们所需的图片是抓取美丽说的网络图片,所以我们需要一个网络请求组件,另外为展示图片还需要添加对应的数据模型.但这两个组件的内容不是本篇博文主要讨论的问题
另外这两个组件相对较基础,就不废太多口水.具体实现可以参看GitHub源码,每次网络请求这里设置为30条数据,这里提到也是为了让你在下面的章节看到相关部分不至于感到疑惑,
添加完这两个基础组件之后,就可以实现缩略图的浏览部分了.为方便起见缩略图view的控制器采用UICollectionViewController,在viewDidLoad函数中设置流水布局样式,实现collectionview的datasource,delegate.这部分都是一些常规的写法,这里要关注的是datasource和delegate的下两个函数.
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
// 从缓存池中取出重用cell
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(reuseIdentifier, forIndexPath: indexPath) as? CollectionViewCell
// 从模形数组中取出相应的模形
let item = shopitems[indexPath.item];
// 设置模形数据为显示缩略图模式
item.showBigImage = false
// 把模形数据赋值给cell,由cell去决定怎样显示,显示什么内容
cell?.item = item
// 当滑动到到最后一个cell时请求加载30个数据
if indexPath.item == shopitems.count - 1 {
loadMoreHomePageData(shopitems.count)
}
return cell!
}
这里为使Demo不过于复杂,没有用什么"上拉加载更多"控件,每次滑动到到最后一个cell时请求加载30个数据方式同样能获得良好的滑动体验
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
// 当点击某个cell时, 创建大图浏览控制器
let photoVC = PhotoBrowseCollectionVC()
// 当前点击cell的indexPathw传给控制器,以使大图浏览器直接显示对应图片
photoVC.indexPath = indexPath
// 当前模型数组的内容传给控制器,以使大图浏览能左右滑动
photoVC.items = shopitems
// 先以正常形式modal出大图浏览
presentViewController(photoVC, animated: true, completion: nil)
}
这里先以正常的样式(从底部弹出)modal出大图浏览视图,当缩略图和大图的逻辑跳转逻辑完成后再来完善画动逻辑
大图浏览的实现
与缩略图一样,大图浏览也是一个collectionView.这里为大图浏览控制器添加了一个便利构造器,以便在点击缩略图时快速创建固定流水布局的collectionView.
convenience init() {
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: UIScreen.mainScreen().bounds.width + cellMargin, height: UIScreen.mainScreen().bounds.height)
layout.minimumLineSpacing = 0
layout.minimumInteritemSpacing = 0
layout.scrollDirection = .Horizontal
self.init(collectionViewLayout: layout)
}
在Demo 结构分析一节中遗留了一个问题,其实要实现全屏图像间隔效果非常简单,只要把collectionView和cell的宽设置为屏宽加固定的间距并且cell之间间距为0
而图片只显示在屏幕正中间(图片与屏等宽),这样在开启pagingEnabled的情况下每次滑动都是滑动一个(图片宽度+间距),相当于在cell中留了一个边距来作间隔而不是在cell
外做间隔,可以参看下图
上图中有两个cell,cell的间距是零.开启pagingEnabled时,每次移动都是一个cell的宽,这样停止滑动时间隔就不会出现在屏幕中了.
大图浏览的collectionView的实现代码几乎与缩略图一样,需要注意的是当modal出大图的时候collectionView是要直接显示对应大图的,这也是为什么在缩略视图控制器的didSelectItemAtIndexPath函数中要传递indexPath的原因.
override func viewDidLoad() {
super.viewDidLoad()
// 大图colletionview的frame
collectionView?.frame = UIScreen.mainScreen().bounds
collectionView?.frame.size.width = UIScreen.mainScreen().bounds.size.width + cellMargin
// 开启分页
collectionView?.pagingEnabled = true
// 注册重用cell
collectionView?.registerClass(CollectionViewCell.self, forCellWithReuseIdentifier: cellID)
// collectionView显示时跳转到应的图片
collectionView?.scrollToItemAtIndexPath(indexPath!, atScrollPosition: .Left, animated: false)
}
上面代码中scrollToItemAtIndexPath函数的atScrollPosition参数的意思是停止滚动时对应的cell与collectionView的位置关系,Left是cell的左边与colletionview的
左边对齐.其它的对应关系可依此类推就不废话了. collectionView的比较重要代理函数的实现如下
override func collectionView(collectionView: UICollectionView, cellForItemAtIndexPath indexPath: NSIndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCellWithReuseIdentifier(cellID, forIndexPath: indexPath) as! CollectionViewCell
let item = items![indexPath.item]
item.showBigImage = true
cell.item = item
return cell
}
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
dismissViewControllerAnimated(true, completion: nil)
}
说重要是因为要与缩略图控制器的代理函数对比看,cellForItemAtIndexPath只是常规的设置数据,选中cell直接dismiss当前控制器.
至此缩略图和大图的跳转逻辑你已经清楚了,下面的部分才本博文要讲的真正内容.其实上面分析那么多废话也是因为present和dismiss的动画与跳转前后两个控制器有密切关系
modal出一个View的原理
默认从底部弹出view的modal方式是将要显式的view添加到一个容器view中,然后对容器view添加动画效,动画结束后把跳转之前控制器的view从window中移除.在window中之前
的view完全被弹出的view替代最终看到如下图的视图结构
如你在上图中看到的,黑色的是window,蓝色的为弹出的View,而中间的就是容器View.容器view的类型是UITransitionView
dismiss的过程是present的逆过程,除了从底部弹出的动画UIKit还提供了多种动画效果可以通过设置弹出控制器modalTransitionStyle属性.
这里有个需要注意点,当设置modalPresentationStyle为Custom时原控制器的view并不会从window中移除.同时如果设置了transitioningDelegate
那么modalTransitionStyle设置的动画效果将全部失效,此时动画全权交给代理来完成. UIViewControllerTransitioningDelegate协议包含五个函数
这里只需要关注Getting the Transition Animator Objects的两个函数,这两个函数都需要返回一个实现UIViewControllerAnimatedTransitioning协议的实例对象,
具体的动画逻辑将在这个实例对象的方法中完成.
添加点击跳转到大图浏览动画
按上一节的分析需要在点击缩略图时把大图控制器的modalPresentationStyle设为.Custom,并且过渡动画(transitioningDelegate)设置代理对象,具体代码如下
override func collectionView(collectionView: UICollectionView, didSelectItemAtIndexPath indexPath: NSIndexPath) {
let photoVC = PhotoBrowseCollectionVC()
photoVC.indexPath = indexPath
photoVC.items = shopitems
photoVC.transitioningDelegate = modalDelegate
photoVC.modalPresentationStyle = .Custom
presentViewController(photoVC, animated: true, completion: nil)
}
modalDelegate是ModalAnimationDelegate的实例对象,其实现了UIViewControllerTransitioningDelegate协议方法,animationControllerForPresentedController
返回本身的实例对象,所以ModalAnimationDelegate也要实现UIViewControllerAnimatedTransitioning协议方法.
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return self
}
现在具体的动画逻辑就转到了UIViewControllerAnimatedTransitioning协议的animateTransition方法中.要实现从选中的图片慢慢放大的效果分成如下几步
取出容器view,也就是上一节提到的UITransitionView实例对象
取出要弹出的目标view,在这里就是展示大图的colletionview,并添加到容器view
新建UIImageView对象,得到选中的UIImage对像,及其在window上的frame
把新建的UIImageView对象添加到容器view
设置新建UIImageView的放大动画,动画结果束后从容器view中移除
通知系统动画完成(主动调用completeTransition)
把动画的实现分解开来是不是清晰很多了,具体实现还是得参看代码
func presentViewAnimation(transitionContext: UIViewControllerContextTransitioning) {
// 目标view
let destinationView = transitionContext.viewForKey(UITransitionContextToViewKey)
// 容器view
let containerView = transitionContext.containerView()
guard let _ = destinationView else {
return
}
// 目标view添加到容器view上
containerView?.addSubview(destinationView!)
// 获取目标控制器
let destinationController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as? PhotoBrowseCollectionVC
let indexPath = destinationController?.indexPath
// 跳转前的控制器
let collectionViewController = ((transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)) as! UINavigationController).topViewController as! UICollectionViewController
let currentCollectionView = collectionViewController.collectionView
// 当前选中的cell
let selectctedCell = currentCollectionView?.cellForItemAtIndexPath(indexPath!) as? CollectionViewCell
// 新建一个imageview添加到目标view之上,做为动画view
let annimateViwe = UIImageView()
annimateViwe.image = selectctedCell?.imageView.image
annimateViwe.contentMode = .ScaleAspectFill
annimateViwe.clipsToBounds = true
// 被选中的cell到目标view上的座标转换
let originFrame = currentCollectionView!.convertRect(selectctedCell!.frame, toView: UIApplication.sharedApplication().keyWindow)
annimateViwe.frame = originFrame
containerView?.addSubview(annimateViwe)
let endFrame = coverImageFrameToFullScreenFrame(selectctedCell?.imageView.image)
destinationView?.alpha = 0
// 过渡动画执行
UIView.animateWithDuration(1, animations: {
annimateViwe.frame = endFrame
}) { (finished) in
transitionContext.completeTransition(true)
UIView.animateWithDuration(0.5, animations: {
destinationView?.alpha = 1
}) { (_) in
annimateViwe.removeFromSuperview()
}
}
}
这里的关键是怎样通过transitionContext拿到两个控制器.通过UITransitionContextFromViewControllerKey拿到的是转跳前控制器的父控制器,由于Demo中缩略图控制器内嵌了导航控制器所以在Demo中拿到就是导航控制器,经过一系列的转换才能拿到选中的图片.拿到选中的图片后需要计算动画开始和结束的frame,开始的frame是将选中的cell座标直接转换到window上
结束的frame是UIImageView放大到屏宽并居中的frame,具体计算方法参看Demo的coverImageFrameToFullScreenFrame全局函数.
另外UIViewControllerAnimatedTransitioning协议另一个必须要实现的函数是transitionDuration,这个函数决定了动画执行的时长.
func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
return 1.0
}
添加轻击回到小图浏览动画
轻击dismiss的过程与上一节弹出正好相反,但仍有所区别.过程如下:
取出弹出的大图colletionview,得到当前轻击的图片
新建UIImageView作为动画view,并把上一步得到的image给新建UIImageView
得到选中图片在window上的frame,并设置为新建UIImageView动画的开始frame
得到当前轻击的大图对应的缩略图的frame,并将其做为动画结束frame
执行动画,动画结束后移除UIImageView
通知系统动画完成(主动调用completeTransition)
与present过程不同的是UITransitionContextFromViewControllerKey和UITransitionContextToViewControllerKey两个key正好相反,present过程的FromVC是缩略图的父控制器,toTV是大图浏览控制器.而dismiss与present是相反的.
func dismissViewAnimation(transitionContext: UIViewControllerContextTransitioning) {
let transitionView = transitionContext.viewForKey(UITransitionContextFromViewKey)
let contentView = transitionContext.containerView()
// 取出modal出的来控制器
let destinationController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) as! UICollectionViewController
// 取出当前显示的collectionview
let presentView = destinationController.collectionView
// 取出控制器当前显示的cell
let dismissCell = presentView?.visibleCells().first as? CollectionViewCell
// 新建过渡动画imageview
let animateImageView = UIImageView()
animateImageView.contentMode = .ScaleAspectFill
animateImageView.clipsToBounds = true
// 获取当前显示的cell的image
animateImageView.image = dismissCell?.imageView.image
// 获取当前显示cell在window中的frame
animateImageView.frame = (dismissCell?.imageView.frame)!
contentView?.addSubview(animateImageView)
// 缩略图对应的indexPath
let indexPath = presentView?.indexPathForCell(dismissCell!)
// 取出要返回的控制器view
let originView = ((transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as! UINavigationController).topViewController as! UICollectionViewController).collectionView
var originCell = originView!.cellForItemAtIndexPath(indexPath!)
// 得到返回后对应cell在window上的frame
let originFrame = originView?.convertRect(originCell!.frame, toView: UIApplication.sharedApplication().keyWindow)
UIView.animateWithDuration(1, animations: {
animateImageView.frame = originFrame!
transitionView?.alpha = 0
}) { (_) in
animateImageView.removeFromSuperview()
transitionContext.completeTransition(true)
}
}
present和dismiss时都会调用到UIViewControllerAnimatedTransitioning协议的animateTransition方法,为区分dismiss和present的动画,定义一个属性isPresentAnimationing表明当前要执行的是dismiss还是present,而当前执行的动画是由UIViewControllerTransitioningDelegate协议的animationControllerForPresentedController和animationControllerForDismissedController两个函数决定的.
func animationControllerForPresentedController(presented: UIViewController, presentingController presenting: UIViewController, sourceController source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresentAnimationing = true
return self
}
func animationControllerForDismissedController(dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
isPresentAnimationing = false
return self
}
func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
isPresentAnimationing ? presentViewAnimation(transitionContext) : dismissViewAnimation(transitionContext)
}
要注意的问题
其实上在dismiss动画逻辑留下了一个坑,dismiss时需要获取对应缩略图的cell进而得到动画结束的frame,而获取这个cell用了cellForItemAtIndexPath方法
...
let originView = ((transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as! UINavigationController).topViewController as! UICollectionViewController).collectionView
var originCell = originView!.cellForItemAtIndexPath(indexPath!)
// 得到返回后对应cell在window上的frame
let originFrame = originView?.convertRect(originCell!.frame, toView: UIApplication.sharedApplication().keyWindow)
...
而cellForItemAtIndexPath只能返回正在显示的cell,没有被显示的cell将返回nil.所以当大图对应的缩略图没有被显示在colletionview中时强制解包就会抛出异常.也就是说当选择查看当前显示缩略图的最后一张对应的大图时就会闪退.解决的办是若用cellForItemAtIndexPath取不到cell则将应的cell滚动到可视范围内,由于cellForItemAtIndexPath需要下一个显示周期才能显示所以要主动调用layoutIfNeeded,实现如下
var originCell = originView!.cellForItemAtIndexPath(indexPath!)
if originCell == nil {
originView?.scrollToItemAtIndexPath(indexPath!, atScrollPosition: .CenteredVertically, animated: false)
originView?.layoutIfNeeded()
}
originCell = originView!.cellForItemAtIndexPath(indexPath!)
let originFrame = originView?.convertRect(originCell!.frame, toView: UIApplication.sharedApplication().keyWindow)
...
总结
上面啰啰嗦嗦写了很多我认为是废话的话,其实实现类似微信微博的图片浏览动画的核心在于dismissViewAnimation和presentViewAnimation函数.本文只是通过一个简单的demo实现了相同的效果,为大家在自己项目中实现类似效果提供一个可参考的思路.当然本人水平有限,或许你知道更简单有效的方法希望也告知我.