[iOS]过渡动画之高级模仿 airbnb

时间:2022-05-12 08:11:00

注意:我为过渡动画写了两篇文章:
第一篇:[iOS]过渡动画之简单模仿系统,主要分析系统简单的动画实现原理,以及讲解坐标系、绝对坐标系、相对坐标系,坐标系转换等知识,为第二篇储备理论基础。最后实现 Mac 上的文件预览动画。
第二篇:[iOS]过渡动画之高级模仿 airbnb,主要基于第一篇的理论来实现复杂的界面过渡,包括进入和退出动画的串联。最后将这个动画的实现部分与当前界面解耦,并封装为一个普适(其他类似界面也适用)的工具类。


这两篇文章将会带你学到如何实现下图 airbnb 首页类似的过渡动画,同时最重要的,你将学会怎么分析类似的动画,并且知道如何动手实现。GitHub 地址在这里。

[iOS]过渡动画之高级模仿 airbnb
如果你没看第一篇,那我建议你去看一下第一篇,因为如果没有第一篇的基础,这篇还是比较难理解的。不如,现在就去吧。
好,准备好了吗?现在开始第二篇。这一篇主要基于第一篇的理论来实现复杂的界面过渡,包括进入和退出动画的串联。最后将这个动画的实现部分与当前界面解耦,并封装为一个普适(其他类似界面也适用)的工具类。

01.这个界面的架构

我们首先来分析一下这个界面的架构。窗口上是一个可以上下滚动的 UITableViewController,每个 UITableViewCell 上有一个可以左右滑动的 UICollectionView,在每一个 UICollectionViewCell 上布局一张封面图片和其他元素。很主流的布局,大致就是这样,对吧?

[iOS]过渡动画之高级模仿 airbnb

02.基于第一篇,我们应该怎么划分这个动画的结构?

上面的动画应该怎么分析呢?什么❓我好像听到有同学在说:“太快了❓根本看不清❓” 好,那我就放慢一点,你再仔细瞧瞧。

[iOS]过渡动画之高级模仿 airbnb

看清楚没❓还没看清❓什么❓只看到它们在动❓有一种轻拿轻放的感觉❓

那我们再看张图吧。如果我们脑洞大一点,不要管界面结构,我们把界面想象成为一个平面,那么我们可以按照下面这张图来划分一下动画结构。

[iOS]过渡动画之高级模仿 airbnb

如你所见,其实动画分为三个部分,UpAnimationPart + CentreAnimationPart + DownAnimationPart,UpAnimationPart 和 DownAnimationPart 的动画可以归为一类,他们只是简单的上移或者下移。重点是中间的 CentreAnimationPart,它和其他部分都有一些区别。

CentreAnimationPart,每张图片都要作为一个单个的个体进行动画,而不能将中间整个模块一起进行动画。为什么呢?因为每张图片最后都要在下个界面的顶部填充满一个控件。这么说太难懂了?意思就是,如果你拿到的是中间区域整体进行动画的话,那么你拿到的将是中间图片区域有遮盖的部分,而右边露出来那片橙色的图片你将拿不到,这个时候当用户点击的刚好又是右边那张露出半边的图片,你将无法实现中间区域的动画。

如果你理解了我所说的,那么你将会理解这两张图片的微妙区别。

[iOS]过渡动画之高级模仿 airbnb
[iOS]过渡动画之高级模仿 airbnb

上面两张图片,第二张图片的动画结构是正确的。

划分完动画结构以后,你应该有一种庖丁解牛的感觉。你有没有感觉到和第一篇文章所实现的系统动画已经很像了。希望你能从这种分析和训练中总结出问题的核心:最难的部分是我们的理解,而不是实现。

03.注意点

既然思路都有了,那赶紧写代码吧?吁... 等等,你的思路真的有了吗?能说出来是什么吗?

没关系,学东西哪有那么快。这不是安慰,这是事情的真相。

我们先来看两个知识点和一个注意点。

3.1.Block 的循环引用

可能你已经隐隐意识到了,这个动画已经难免会用到 block 了,block 有很难搞的“循环引用”,你可能仍然搞不懂什么是“引用环”,说清楚这个问题可能要再写一篇文章才够,所以我也不打算在这里说清楚这个问题。

所以我给你的建议是,凡是你拿不准是不是会出现循环引用的地方,你都这么写:

__weak typeof(self) weakSelf = self;
self.aBlock = ^{
__strong typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return; // 其它代码
...
}

为什么这么写?

  • 解除循环引用的问题。__weak 是弱引用,不会将 self 的引用计数器 +1。_strong 将 weakSelf 引用计数器 +1,以保持对 weakSelf 的持有,但是 strongSelf 是一个局部变量,过完这个代码块,strongSelf 就会自动释放,所以解除了循环引用的可能性。

  • 防止应用奔溃。if (!strongSelf) return; 我们假设一种很常见的情况,当 self 已经释放的时候,这个 block 被调起,然后就去访问一个为 nil 的僵尸对象,比如说将 self 的某个属性插入字典什么的,这个时候往字典里插入空元素,自然会造成应用奔溃,有了这一行代码,就不会再出现类似的情况了。

3.2.循环利用池

我们天天在用的 UITableView 为什么性能这么好,很大一部分原因是得益于循环利用池这个设计思想。

[iOS]过渡动画之高级模仿 airbnb

循环利用池的设计思想可以概括为:

  • 当要用到一个对象的时候,先从 ReusePool 中取,如果 ReusePool 中有缓存就把这个缓存取出来,返还给使用者,然后将这个对象从 ReusePool 中移除。如果 ReusePool 中没有,就创建一个新的对象,返还给使用者。
  • 当一个对象已经移出视野(或不需要使用)的时候,就会将它加入到 ReusePool 中等待再次循环。

基于高性能的这个目的,我们应该给我们的做动画的 UIImageView 实例创建一个 ReusePool。

3.3.如何测试自己计算的 frame 是否正确?

计算 frame 和迁移 frame 是一件很纠结的事情,而且不知道自己究竟有没有算对,如果算不对,就会导致动画错乱。但是如果最后调动画的时候才回过来调 frame,就要反复的检查究竟哪个 frame 算错了。这样是很蛋疼的。

所以我给你一个建议。就是你每算完一个 frame,就在这个 frame 上添加一个占位视图看一下对不对。比方说,像这样添加一个红色的 View 到屏幕上检查一下 frame 对不对:

UIView *redView = [[UIView alloc]init];
redView.backgroundColor = [UIColor redColor];
redView.frame = YourFrame;
[self.view.window addSubview:redView];

3.4.怎么找到 UICollectionViewCell 上那个显示封面图片的 UIImageView?

这个需要你在使用的时候给这个 UIImageView 绑定一个 tag,这样我就能拿到这个 UIImageView。

04.具体实现思路?

让我们总结一下上面分析的内容,看能不能从中分析出我们的具体实现思路。

4.1.动画素材

  • 4.1.1、当用户点击的那一刻,我们首先应该把当前窗口(注意,是窗口 Window)进行截图,备用。

  • 4.1.2、我们需要有一个工具,给这个工具传入裁剪的点和裁剪的类型,就能帮我们把一张图片裁成我们想要的样式。比方说,我们只要截图的上半部分,或者下半部分。

    [iOS]过渡动画之高级模仿 airbnb
  • 4.1.3、我们需要把用户点击的那个 UICollectionViewCell 上面的那张图片的 Frame 迁移到窗口坐标中,然后计算出需要裁剪的点的位置,最后把窗口截屏和这个点传进去进行裁剪,得到我们做动画需要的图片。

  • 4.1.4、现在做动画需要的元素中我们已经有了 UpAnimationPart 和 DownAnimationPart 需要的图片了,现在只差 CentreAnimationPart 需要的可见 Cell 上面的图片了,这个我们通过 UICollectionView 的 visiableCells 可以拿到。这样一来,做动画的素材已经齐备了。

    [iOS]过渡动画之高级模仿 airbnb

4.2.动画起始位置

动画的起始位置应该是这个动画最容易的部分。

  • 4.2.1、UpAnimationPart 的起始位置应该是点击那个 Cell 的图片的 Y 坐标以上。如果把 upTailorY 指定为点击那个 Cell 的图片的 Y 坐标的话,那么:

    CGRect upAnimationImageViewFrame_start = CGRectMake(0, 0, JPScreenWidth, upTailorY);
  • 4.2.2、同理,如果把 downTailorY 指定为为点击那个 Cell 的图片的底部(Y 坐标加上图片的高度)。那么,DownAnimationPart 的起始位置应该是:

    CGRect downAnimationImageViewFrame_start = CGRectMake(0, downTailorY, JPScreenWidth, JPScreenHeight - downTailorY);
  • 4.2.3、而中间可见 Cell 的图片的起始位置,都可以通过坐标系迁移直接得到。这样以后,各部分的动画起点位置我们也都有了,这样以后我们就可以在窗口上添加 UIImageView 了。

4.3.动画终点位置

第一篇里说的考验数学功底的部分终于来,还是有点小激动。其实也很简单,你看一张图就知道了:

[iOS]过渡动画之高级模仿 airbnb
  • 4.3.1、UpAnimationPart 的终点位置很 easy,简单到你可以直接写出来:

    CGRect upAnimationImageViewFrame_end = CGRectMake(0, -upTailorY, JPScreenWidth, upTailorY);
  • 4.3.2、DownAnimationPart 的终点位置是:

    CGRect downAnimationImageViewFrame_end = CGRectMake(0, JPScreenHeight, JPScreenWidth, JPScreenHeight - downTailorY);
  • 4.3.3、CentreAnimationPart 会稍微有点复杂,其实应该分为三种情况的。具体见下图:

    [iOS]过渡动画之高级模仿 airbnb
    • 4.3.3.1、TapImage 这张被点击的图片,它的终点位置应该很容易确定,就是填充屏幕顶部:
      这个没有异议吧?而其他两类需要参考它的位置来定位。

      CGRect tapAnimationImageViewFrame_end = CGRectMake(0, 0, JPScreenWidth, JPScreenWidth*2.0/3.0);
    • 4.3.3.2、TapImage_Left。请看下面这张图,你肯定明白了,对吧?TapImage 的初始宽度我们知道,左侧图片和 TapImage 的左侧间距我们也可以算出来,屏幕宽度我们也知道,现在利用十字相乘法,我们就可以很快拿到下图红色方框里的值,也就是我们要的终点位置的 X 坐标。

      [iOS]过渡动画之高级模仿 airbnb
    • 4.3.3.3、TapImage_Right。这个就不用我再赘述了吧?和上面的情况类似。

05.代码实现

我不打算在文章里粘贴代码了,一个,篇幅已经很长了,再粘贴代码,就会让有些“太长不看”的同学感觉压力很大。二来,代码已经放在 GitHub 上了,看代码还是在 Xcode 中更舒服一点。而且我把 Keynote 也一并放上去了。

06.解耦和抽成工具类

如果你按照这个思路去写的话,你会发现所有的代码都会集中在一个方法里,导致这个方法的代码量有三四百行,非常臃肿。而且进入和退出动画居然耦合在一起,要解耦,要抽工具类又感觉无从下手。这个时候就应该要有一种壮士断腕的勇气:“老子一定要把你抽成工具”的决心。有了这个决心,剩下的就是想办法了。

这个动画有很多参数,所以对于哪些是必须的,要有所取舍。也就是要尝试为工具类设计 API。

/*!
* \~chinese
* @prama indexPath 用户选中的那个UICollectionViewCell的 indexPath.
* @prama collectionView 用户选中的那个UICollectionViewCell的 UICollectionView.
* @prama viewController 动画之前窗口上显示的 viewController.
* @prama presentViewController 动画完成之后要在窗口上显示的 viewController.
* @prama afterPresentedBlock 动画完成之后要在 presentViewController 做的事情.
*
* @return JPContainIDBlock 关闭动画的 block.
*/

对于解耦,我的理解就是,首先写代码之前就要有“尽量不要耦合”的意识。如果项目特别赶时间,你可以暂时不用太理会耦合,这些很深的东西可能需要长期的积累和对于项目的全局观,这些可以等周末有空或者项目之间有空档期的时候再去细细琢磨。

还有就是要有坚韧不拔的意志,有些时候给某个类解耦的时间可能比你重新写一遍花的时间,还多。但是,总结一下,多出来的时间我们都在思考什么?是不是这些时间都用在比实现功能更高一个层次的事务上了?

07.关于JPNavigationController

这个动画得以最后呈现,和我之前的一个框架是分不开的。也就说,如果没有我之前的那个框架做基础,那么这个动画的关闭部分就无法实现。

具体体现在对于 pop 手势的拦截。

#pragma mark --------------------------------------------------
#pragma mark JPNavigationControllerDelegate -(BOOL)jp_navigationControllerShouldPushRight{
[self backBtnClick:nil];
return NO;
}

大致可以概述为,当用户开始 pop 的时候,我们当前控制器会收到代理方法的询问,询问是否需要继续 pop 行为。在我们这个动画中迫切需要收到这个询问,但是不需要继续 pop 行为,所以我们 return NO。


如果你想了解这个框架的实现,你可以看下面这三篇文章:
第一篇:[iOS]UINavigationController全屏pop之为每个控制器自定义UINavigationBar。这篇文章主要是讲述如何实现自定义导航栏的,所有的思路和实现都是 JNTian的。
第二篇:[iOS]UINavigationController全屏pop之为每个控制器添加底部联动视图。这篇文章讲述,如何在已有的自定义导航栏基础上添加自定义的“底部联动视图”。所有的思路和实现都是我自己的。
第三篇:[iOS]UINavigationController全屏pop之为控制器添加左滑push。这次将讲述如何实现左滑push到绑定的控制器中,并且带有push动画。
或者访问我的 GitHub 的JPNavigationController


08.GitHub 地址

GitHub 地址在这里。

感谢分享