微博,微信图片浏览过渡动画的实现

原文刊载在:jmStack的个人主页 转载请说明出处

大家在平常用微信,微博的过程中肯定(对,就是肯定)都有查看过朋友圈和微博所发布的照片,当点击九宫格的某一图片时图片会慢慢的放大并进入全屏,左右滑动查看另一张.轻点图片又会以动画的方式慢慢缩小回到滑动之后对应的图片.说了这么多估计你还是不知道我在讲什么鬼,一张动图胜过千言万语.毕竟语言这东西真不是码农的特长...

微博九宫格
微信九宫格

上面两张gif点开时的动画不是很明显,你可以在真机上查看更真实效果.接下来我会通过一个Demo来介绍实现这种效果的具体思路,如果你有更好的思路,请求赐教


Demo 预览

在开始之前先看一看最终的效果

Demo 预览

这个Demo抓取了美丽说的在线图片,这里对毫不知情的美丽说表示感谢.

在看下面的部分之前假定你已经撑握了Swift,网络请求,会使用UICollectionView等基础组件的技能.如若不能撑握建议先了解相关知识

DemoGitHub地址

Demo 结构分析

在Demo中主要包括两个主要的视图结构:一 缩略图(主视图)的浏览 二 大图的浏览. 这两个视图中所要展示的内容都是有规律的矩形所以都可以用UICollectionView来实现.

两者的区别在于缩略图是垂直方向的布局而大图是水平方向上的布局方式.两个UICollectionView的cell的内容只包含一个UIImageView.在大图浏览视图中有一个

需要注意的细节:为了图片浏览的效果每张图片之间是有一定间隔的,如果让每个cell都填充整个屏幕,图片的宽度等于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替代最终看到如下图的视图结构

modal视图结构

如你在上图中看到的,黑色的是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实现了相同的效果,为大家在自己项目中实现类似效果提供一个可参考的思路.当然本人水平有限,或许你知道更简单有效的方法希望也告知我.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • 缘起 那时,我想要一个这样的图片浏览器: 从小图进入大图浏览时,使用转场动画 可加载网络图片,且过渡自然,不阻塞操...
    囧书阅读 7,008评论 35 107
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,066评论 4 62
  • 好久不见的一个同校朋友,那天晚上过来我宿舍拿东西。坐在床边我们就聊了起来,说着说着就说起了她对象的话题了,她瞬间就...
    李籽儿阅读 179评论 0 0
  • 又是一年春节,今年回家的任然是独身一人,父母多 次催我该找女朋友了,我总是无言以对,其实不是自己不想找,只是没有很...
    兔毛阅读 362评论 1 1
  • 定义 归并排序(英语:Merge sort),是创建在归并操作上的一种有效的排序算法,效率为O(n log n)。...
    craneyuan阅读 516评论 0 9