Layout动画初体验

约束动画的文章要比预计的迟迟来临,最大的原因是没有找到我认为的足够好的动画来讲解约束动画 —— 当然了,这并不是因为约束动画太难。相反,因为约束动画实在太简单了,反而没有足够多的简单动画素材让我选用。下面这个动画取自于朋友公司的app,我仿做了一个,作为今天的demo,具体效果如下:

关于约束

在这一小节我会简单的介绍一下约束的用法,如果您已经在使用storyboard进行开发了,那么可以跳过这一节。

假设现在有这么一个需求,你需要将文章显示在界面的中间位置,大致是左右空30pt、上方间隔20pt,效果图如下:

你满心不在意的想这个任务多简单啊,于是挥洒的写下了这么一段代码:

self.textView = [[UITextView alloc] initWithFrame: CGRectMake(30, 20, self.view.bounds.size.width - 60, 0)];
self.textView.text = ..........
[self.textView sizeToFit];

然后运行你的代码,ok,效果不错。但请等等,按住command + 左右方向键,你的界面变成了这样:

你很快就意识到问题所在。好的,你接收了屏幕转向的通知UIApplicationWillChangeStatusBarOrientationNotification,然后在回调里面重新修改了文本的宽度:

- (void)notifyToAdjustTextView: (NSNotification *)notification
{
    switch ([UIDevice currentDevice].orientation) {
        case UIDeviceOrientationLandscapeLeft:
        case UIDeviceOrientationLandscapeRight:
            CGRect frame = self.textView.frame;
            frame.size.width = self.view.bounds.size.height - 60;
            self.textView.frame = frame;
            [self.textView sizeToFit];
        //more
        ......
    }
}

这次再次运行你的代码,不管你怎么转向,文本都稳稳的显示在中心,你成功了!但是这样工作有些太繁杂了 —— 你做了这么多的工作只是为了解决一个布局问题。因此,这就提及到了我们今天的主题:约束

在你新建的项目中总会存在一个Main.storyboard,让我们打开这个文件,在xcode为我们创建好的控制器里面直接添加文本:

点击选中textView,xcode为我们提供了下方的几个按钮来给我们的控件添加约束。我在这里给控件添加了距离上方20pt、左右距离边缘30pt的约束,完成后点击Add 3 Constraints约束就算添加完成了:

现在运行你的项目,尝试屏幕转向,无论如何文本总是距离上方20pt,左右距离30pt。使用约束让我们的工作更加的简单不是吗?

所有的动画都是将一连串的计算展示开来的过程,约束动画也不例外,那么约束是如何计算的呢?以上面的基础约束demo为例,在我们为文本控件添加了约束之后,故事板上的textView多了一些直线,每一条直线代表一个约束。双击这个约束我们可以设置更多东西。

以这个约束为例,所有的约束计算都遵循这样的计算:

TextView.Left = 1 * superView.Left + 30

父视图的left坐标点位置总是基于0pt的位置进行计算的,因此与父视图的约束计算总是以constant的值为结果。因此修改constant的大小并重新绘制界面可以产生动画。

关于autolayout使用的更多教学可以看这篇文章

动画分析

在开工之前分析代码的实现过程可以让我们对动画的实现有更清晰的了解。可以看到,动画建立在tableView的滚动条件上,那么监听tableView的滚动偏移是动画实现的核心部分。(ps:为什么是kvo而不是直接代理回调呢?当我们无法保证工程中tableView的代理和动画实现方是否为同一对象时,使用kvo是最稳妥的选择

根据用户滑动的时候,我们可以总结出下面这几个动画效果:

  • 导航栏标题淡出颜色渐变
  • 导航栏标题上移
  • 红色的背景层上移并隐藏
  • 倒计时信息和文本信息上移显示在导航栏中

综合这些信息我写了一些需要用到的宏定义:

define OBSERVERKEY @"contentOffset"            ///<    监听属性
define MAXMOVE 64.0                            ///<    滚动最大值
define TITLEMAXMOVE 16.0                       ///<    导航文本位移最大值
define TOPANDBOTTOMCONSTANT 9                  ///<    红色背景上下间距约束值

本文的动画可以归类为视觉差动画,通过利用不同视图的层次在视图移位形成视觉动画效果,因此我们可以先将动画中发生了视觉位移的视图的层次写出来,这样会帮助我们更好的实现动画。

在动画过程中,红色的背景层上移之后就不再显示,因此红色的背景图层肯定位于导航栏的蓝色视图下方。而倒计时和文本信息上移之后依旧显示,可以肯定这些视图处在导航栏视图上面。因此可以得出这么一个层次:文本信息和倒计时控件 -> 导航栏 -> 红色背景。另一方面如果在动画中同时移动这些文本信息和倒计时控件,代码量肯定很多,所以我们可以把这些控件全部当做一个透明的UIView的子视图,然后简单的移动这个UIView就可以了。下面我放上一张官方文档中苹果对于导航栏控制器的视图层次图:

结合上面的结论,我们可以知道:

  • 透明视图处在Custom view hierarchyNavigation View中间
  • 红色背景处在Navigation viewUIWindow之间

由于在本文的demo中动画控制器作为一级界面,基于简(tou)单(lan)方(shao)便(xie)的原则,我使用了UIViewController,并在上面添加了一个冒充导航栏的view来实现的。如果动画实现需要在二级以上的界面实现,你可能需要继承UINavigationController来自定义这个效果。

准备工作

首先我们在故事板中将界面搭建起来,我们从右下侧的控件栏拖出来一个UIView,注意这个UIView一定要拖拉到顶部在状态栏以内,否则你创建的约束顶部将会以状态栏下方开始,比预期的位置下移了20pt

其次在这个导航栏view上面拉进来一个UILabel关联文件名为navigationTitle,并设置约束为下、左右三个方向的约束为0,高度设置为44pt限制。文本居中,并修改为白色字体跟系统字体18号

最重要的是创建一个用于添加倒计时信息和一些label的透明视图,添加过程以及约束建立我就不再多说了,这是我添加完成之后的图示:

完成这些工作之后,在左侧的视图层次栏里面移动这些视图的位置,确保红色背景的视图在导航栏视图的下方,内容透明视图在导航栏上方,接着就将一部分关键的约束关联到文件中。这些属性关联包括:

  • 滚动视图tableView
  • 透明视图内部子视图的居中约束titleCenterX
  • 透明背景视图的顶部约束contentTopConstraint
  • 红色背景视图的顶部约束backgroundTopConstraint
  • 还有伪装的导航栏标题文本的底部约束labelBottomConstraint

动画制作

首先我们需要监听tableView的滚动,这一部分代码我通常写在viewDidAppear:方法中。同样的,我喜欢在viewDidDisappear:中释放监听或者通知。

- (void)viewDidAppear:(BOOL)animated
{
    [super viewDidAppear: animated];
    [self.tableView addObserver: self forKeyPath: OBSERVERKEY options: NSKeyValueObservingOptionNew context: nil];
}

- (void)viewDidDisappear:(BOOL)animated
{
    [self.tableView removeObserver: self forKeyPath: OBSERVERKEY];
}

在前面我已经说过滚动视图的动效了,那么我们在监听中需要获取滑动偏移,然后计算出滚动效果百分比:

CGFloat yOffset = self.tableView.contentOffset.y / MAXMOVE;
yOffset = MAX(0, MIN(1, yOffset));

这个数值会随着用户的滚动增大逐渐增加到1就不再增加,当到达1的时候就是动效的最终效果。在这过程中,导航栏的标题总共存在上移、渐变隐藏两种效果:

self.navigationTitle.alpha = 1 - yOffset;
self.labelBottomConstraint.constant = yOffset * TITLEMAXMOVE;

接着是红色的背景视图和倒计时这些控件的上移,其中所有的数据展示的控件都被添加到透明的视图上,所以我们只需要移动透明视图:

CGFloat yPoint = TOPANDBOTTOMCONSTANT - yOffset * MAXMOVE;
self.backgroundTopConstraint.constant = self.contentTopConstraint.constant = yPoint;

ok,所有组件的移动代码已经写完了,运行代码试一试。你发现了在上移过程中,透明视图的子控件似乎上移的有些过了。

正常情况下导航栏的高度是44pt,上面有个高度为20pt的状态栏,因此在动效中我设置的移动最大距离为44+20=64pt。而在demo中我们可以看到在移动64pt之后红色背景和透明视图确实移动到了64pt的中心,就是说在这两个视图高度为45pt的情况下,上面有9.5pt的尺寸进入了状态栏的范围中。因此,我们在移动动画过程中,如果要让文本信息能够在导航栏的位置上居中,我们还需要让这些文本信息下移9.5pt的距离。因此在监听回调中添加这么一句代码:

self.infoCenterX.constant = TOPANDBOTTOMCONSTANT * yOffset;

添加完这句代码再次运行,我们的动效就完成了!

尾言

我想说,作为自动约束动画的第一篇博客,我构思了很久。因为对于约束动画来说,这个demo的实现更像是UIView动画式的实现 —— 修改某些坐标值。但最终我还是觉得使用这个demo可以更快速的从UIView动画中转入约束动画内,至于下一篇我会讲解如何调用重绘方法来实现约束重制的动效实现以及在动画中替换更新约束。

本文demo

PREVIOUSLayout动画的更多使用
NEXTTransform和KeyFrame动画