×

卡片动画 Card Animation

96
seedante
2015.10.08 23:42* 字数 2868

动画原型来自 Dribbble

Card Animation.gif

Demo 效果:
效果图和现实.gif

Update: 最近重构了下,方便集成。
Update: 有国外热心网友针对我的 Demo 修改了下可以很方便地在你的项目里使用该效果,具体请看 Github 上的说明。

源代码:https://github.com/seedante/CardAnimation.git
关键词:CALayer, transform, anchorPoint, Auto Layout

动画分析

首先要解决翻转动作。看下图的旋转示意图,使用 UIView 的 transform 属性是无法完成上图的动作的,因为它只支持 Z 轴的旋转;这里必须使用 CALayer 的 transfrom 属性,后者支持三个纬度的旋转。


Core Animation Rotation - iOS Core Animation Advanced Techniques

上面的卡片动画是沿着 X 轴旋转,使用CATransform3DRotate(baseTransform, angle, 1, 0, 0)CATransform3D系列函数生成的 transform 值都是对传入的baseTransform 的基础上累积的变化,因此在代码里调整到需要的效果经常会看到不断对某个 transform 值迭代。

var flipTransform3D = CATransform3DIdentity//从原始状态开始
flipTransform3D.m34 = -1.0 / 1000.0//设定视觉焦点,分母越大表示视图离我们的距离越远,数值大有什么好处呢,你会发现翻转效果就不会产生你讨厌的侧边幅度过大的问题。
flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI), 1, 0, 0)//沿 X 轴旋转180度
//在上面效果的基础上再向右方和下方分别移动100单位 
var thenMoveTransform3D = CATransform3DTranslate(flipTransform3D, 100, 100, 0)

其次这里的旋转不是沿着默认的中心点旋转的,而是视图的底部,这意味着我们需要调整 anchorPoint 为(0.5, 1),然而我们都知道调整 anchorPoint 后会导致视图的 position 移动,关于 position 和 anchorPoint 的关系,推荐一篇我见过的说得最清楚的博客

关于卡片的摆放,很多人应该都知道「近大远小」的透视原理,我最早知道这个是在鸟山明的漫画小剧场里看到这种作画技巧来实现在二维平面上实现不同距离的景物的纵深感觉。上面的动画里每张卡片在 X 轴和 Y 轴方向的差距并不是想等的,这个细节很赞。而且照片边框的宽度也是不一样的,这也和前面这个细节匹配。所以,我们要设定卡片之间的垂直间距以及水平间距,还有卡片的边框宽度,可以设置一个函数来计算这些参数,合适的数值需要通过调试直到达到你的要求为止。翻转动作完成后,后续的卡片依次前进到前面卡片的位置,这里主要是 frame(size 和 position)以及 borderWidth 的变化。

动画里卡片的亮度是不一样的,非常赞的细节。解决方案是在图像前添加一个前景视图,纯黑的透明视图,通过调整 alpha 属性来调节黑暗的程度。这里的 alpha 值,卡片的垂直距离以及白色边框的调整都可以通过设定算式来计算,这部分就不啰嗦了。

动画的细节非常重要。

与 Auto Layout 有关的部分

这几天重写了这个动画,完全采用 Auto Layout 来实现。Raywenderlich.com 家的文章 Part IPart II 总结了传统的 Spring-Strut 布局与 AutoLayout 布局的差异,前者描述了 superView 与 subView 之间的布局关系,但缺乏对平行的 subView 间的布局描述,Auto Layout 补上了这个缺。

在 Auto Layout 的世界里,视图的位置和大小都由在视图上添加的约束决定,这个过程很像我们针对视图的尺寸和位置等数据设置了一堆方程式来交给 Auto Layout 来运算。如果这堆方程式是可解的,那么视图的布局就是确定的;如果方程无解,就会发生冲突,你将在控制台看见一大堆的报告;如果方程式条件不足,Auto Layout 无法给出唯一解,就没法确定视图的布局。

在调整各卡片距离时,Auto Layout 可以做到自动调整:后面的卡片添加对前面的卡片的距离约束,修改第一张卡片的位置约束,就能自动调整其他卡片的位置,如果用 frame 来实现,得去修改每一张卡片的 frame。不过在这次的 Auto Layout 实现里,我还是选用了 frame 的策略,修改每一张卡片相对父视图 centerY的约束。为何?因为,前面的卡片可能会被移除出视图,这样约束也会随之消失,或者前面的卡片会被重用而修改约束,此时两者之间的约束关系就需要发生变化。那么,全部针对父视图的 centerY 添加约束,虽然麻烦需要逐个修改,但这个约束条件就稳定多了。

在 storyboard 里的视图只保存自己与自身子视图之间的约束以及自身子视图之间的约束。那么卡片视图的约束就保存在其父视图的约束里,找出来修改:

UIView.animateWithDuration(0.3, {
    let centerXConstraint = superView.constraints.filter({$0.firstItem as? UIView == subView && $0.secondItem as? UIView == superView && $0.firstAttribute == .CenterX})[0]
    centerXConstraint.constant = 200
    //修改约束后,要求父视图立刻重新布局。虽然上面的修改本身是即时的,但需要这样才能用动画表现
    superView.layoutIfNeeded()
})

在翻转前保证要翻转的卡片的 anchorPoint 移动到了卡片的底部,也就是(0.5, 1)。之前这么处理 anchorPoint,原因见上面提到的博客

//调整 anchor point,并且保持视图位置不漂移。
cardView.frame = frame
cardView.layer.anchorPoint = CGPointMake(0.5, 1)
cardView.frame = frame

使用 Auto Layout 时该如何呢?statckoverflow 上两年前就讨论这问题了,最高票回答非常精彩,还顺带回答了 transform 与 Auto Layout 的问题,解决方案是将要调整的视图内嵌在容器视图里,在容器视图内调整 anchorPoint 和旋转,一举两得。不过,我已经找到另外一种更简单的方法。 Auto Layout 会将视图的约束转化为 frame,我们只需要用约束的方式做同样的事情就可以了。修改 anchorPoint 后,视图的位置发生了移动,那么补偿这段移动。具体的计算方法可能要根据约束的条件来决定,这点不如调整 frame 时简单。

 let oldConstraintConstant = centerYConstraint.constant
 subView.layer.anchorPoint = CGPointMake(0.5, 1)
 //关键代码:anchor point从(0.5,0.5)->(0.5,1),视图会往上移动自身高度的一半,那么补偿这段高度
 centerYConstraint.constant = oldConstraintConstant + subViewHeight/2 

该怎么调整所有卡片视图的 frame?使用 transform 来调整大小会将 Y 轴上的间距也缩放了,比如你设定两个卡片 Y 轴上的间距为10,缩放后这个间距也按卡片本身的缩放比例缩小了,Pass;使用 Auto Layout 后,只能直接修改宽度和高度约束了,这个动画里我设定了宽高比,只需要修改宽度约束就可以了,问题是采用约束来确定位置时,可用的方案很多,但最终的效果和采用的约束有很大的关系( anchorPoint 是缩放的中心点,修改宽度和高度的效果似乎和它也有点关系,有点迷糊了,暂且记下)。我希望达到的效果是在顶部距离按照计算的结果排列,但很多时候修改宽度约束同时也会影响这个距离。比如添加的约束是 Bottom,那么卡片的底部距离是按照计划的那样;使用 Top 时才会达到我们要的效果。

动画里需要使用 transform 来实现翻转,那么 Auto Layout 与 transform有冲突吗?从 iOS 8 起两者相处得还算愉快,如果你需要适配8之前的版本,抱歉,本文就不解决了,在 iOS 8 之前里还是用 frame 来调整吧,两者的纠葛推荐阅读这篇文章:Constraints & Transformations。实际上 transform 跟 Auto Layout 没有交集,AutoLayout 只对约束有效,transform 并没有修改约束条件,两者互不干扰。而 transform 跟 frame 的关系也很有意思,transform 对视图的 bounds 和 center 两个属性并没有影响,只对 frame 有影响,自己可以在代码中验证一下。Frame 的文档指出视图的 transform 不为 identity 时,该值是 undefined 的,应该被忽略。很多地方将 undefined 直译为未定义的,在这个语境下感觉很奇怪,我觉得应该是说此时无法确定 frame 的值。

在代码里生成 UIView 时,切记将translatesAutoresizingMaskIntoConstraints属性值修改为false。在 storyboard 里生成的视图该属性默认为 false,在代码里生成的视图该属性默认为 true。这个属性用来决定是否将 frame 驱动的传统 Spring-Strut 布局模式也就是 autoresize mask 与 AutoLayout 模式混合。当translatesAutoresizingMaskIntoConstraints属性为 true 时,视图的 frame, bounds, center 等属性的变化转化为约束。这个机制的本意是在不手动添加约束的情况下让 Auto Layout 来自动处理。一般而言视图在使用前约束就被我们添加好了(我个人主要是出于保险的心理),开启这个机制的结果往往是约束冲突,让很多人措手不及,说好的混合使用呢。如果视图的布局将来会变化,就不要开启这个属性,布局的事情交给 Auto Layout。关于这个属性怎么使用,我在这片文章里探讨了下:使用 Auto Layout 的典型痛点和技巧

透明的翻转动画

翻转卡片时,当卡片与屏幕垂直,继续翻转时,此时应该只能看到卡片的背面,因为卡片正面的内容应该被遮挡了,然而 iOS 提供的所有翻转方式里视图层都是透明的。解决办法是:设置卡片视图的背景颜色为需要的颜色,在翻转过程中当卡片视图与屏幕垂直时将图片视图隐藏,Bingo,同时,完善细节,将 borderWidth 修改为0。为什么要隐藏图片视图呢?即使设置了卡片视图的背景色为非透明色,并且将卡片视图本身设置为非透明,也不会达到我们要的效果,只有将图像视图隐藏才行。

另外,在普通的 UIView 动画里翻转 180℃ 是有问题的,而且我们需要在翻转到 90℃的时候将卡片里的内容隐藏,使用 keyFrame 动画可以完美解决这个问题。另外,修改 borderWidth 在所有 UIView 动画里都不会产生动画的效果,也就无法之前的那样在动画中途修改 borderWidth,这时候只好使用dispatch_after了。

var flipDownTransform3D = CATransform3DIdentity
//m34这个值用来表示视觉上焦点的位置,不明白的话,只需要知道设置的值越大相当于卡片离你的距离越远,
flipDownTransform3D.m34 = -1.0 / 2000.0  
    
UIView.animateKeyframesWithDuration(duration, delay: 0, options: UIViewKeyframeAnimationOptions(), animations: {
    //普通的 UIView 动画旋转180度不会执行,而是直接跳转,其他的角度则没有问题。使用 key frame 动画则没有问题,而且在 Button Action 里也没有问题。
    UIView.addKeyframeWithRelativeStartTime(0, relativeDuration: 1, animations: {
    let flipDownHalfTransform3D = CATransform3DRotate(flipDownTransform3D, CGFloat(-M_PI), 1, 0, 0)
        headCardView.layer.transform = flipDownHalfTransform3D
    })
    //这个只执行1%动画时间的动画的目的是将图像视图隐藏,用于实现非透明背景
    UIView.addKeyframeWithRelativeStartTime(0.5, relativeDuration: 0.01, animations: {
        headCardView.hiddenContent()
    })
}, completion: { _ in
    //调整后续的卡片
})

交互动画

添加 pan 手势来实现可交互的动画。在手势的开始阶段根据速度的正负来判断执行的操作。

case .Began:
if velocity.y > 0{
    //向下翻转卡片
    isInitiallyDown = true
}else{
    //将下方的卡片翻回上面
    isInitiallyDown = false
}

在手势的变化阶段,并不提交动画,而是调整卡片视图的翻转角度与进度匹配。

//在 pan 手势里,根据手势在屏幕上移动的距离来判断进度:
let percent = gesture.translationInView(view).y/150 //y 值可以为负,代表手指向上移动
//0<percent<1,向下翻卡片
flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI) * percent, 1, 0, 0)
headCardView?.layer.transform = flipTransform3D
//-1<percent<0,将前一张卡片向上翻
flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI) * (percent + 1.0), 1, 0, 0)
previousHeadCardView?.layer.transform = flipTransform3D

//翻转的初始方向向下时    
if percent >= 0.5{
    //向下翻转,当卡片与屏幕垂直时,将图片隐藏,此时将只会看到视图的背景色,同时要注意调整 borderWidth 为 0,因为 borderWidth 的效果在卡片翻转后依然存在。
        headCardView.hiddenContent()
        headCardView?.layer.borderWidth = 0
}else{
    //向上回翻,当卡片与屏幕垂直时,恢复图像视图和 boardWidth。
        previousHeadCardView.restoreContent()
        previousHeadCardView?.layer.borderWidth = 0
}

在手势结束时,提交动画来完成剩下的事情。

//翻转初始方向为向下时,翻转程度达到一半时自动完成这个翻转
if percent >= 0.5{
    UIView.animateWithDuration(0.3, animations: {
       //此时卡片剩余翻转的角度小于 180℃,没有问题,这个奇怪。
        cardView?.layer.transform = flipTransform3D
        }, completion: { _ in
            //将翻转的卡片隐藏以及调整后面的卡片
    })
}else{
    //不然就原路返回,取消翻转
    UIView.animateWithDuration(0.2, animations: {
        cardView?.layer.transform = CATransform3DIdentity
    })
}

深入阅读:

  1. 彻底理解 position 与 anchorPoint
  2. How I Learned to Stop Worrying and Love Cocoa Auto Layout
  3. Auto Layout Guide
  4. WWDC15 Session 219: Mysteries of Auto Layout, Part 1
  5. WWDC15 Session 219: Mysteries of Auto Layout, Part 2
  6. Auto Layout Tutorial in iOS 9 Part 1: Getting Started
  7. Auto Layout Tutorial in iOS 9 Part 2: Constraints
  8. stackoverflow: How do I adjust the anchor point of a CALayer, when Auto Layout is being used?
  9. Constraints & Transformations: How Auto Layout quietly became transform-friendly in iOS 8
请我喝罐可乐^_^
动画
Web note ad 1