今天要实现这个动画,来自 Dribbble。
实际效果:
Card Animation.gif
源代码: https://github.com/seedante/CardAnimation.git
关键词: transform, anchor point, frame-based layout, auto layout
看点总结:实践了基本的 transform 动画,趟过代码中建立视图与 AutoLayout 配合的坑,解决了旋转时动画视图背景透明以及 AutoLayout 中调整 anchorPoint 的难题。
动画分析
首先是翻转动作。看下图的旋转示意图,使用 UIView 的 transform 属性是无法完成上图的动作的,因为它只支持 Z 轴的旋转;这里必须使用 CALayer 的 transfrom 属性,后者支持三个纬度的旋转。
这里是沿着 X 轴旋转,使用CATransform3DRotate ( baseTransform, angle, 1, 0, 0)。transform 的每次赋值都是针对原始状态的调整,而不是前一个 transfrom 的调整。而生成 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)
然后这里的旋转不是沿着默认的中心点旋转的,而是视图的底部,这意味着我们需要调整 anchor point 为(0.5, 1),然而我们都知道调整 anchor point 后会导致视图的 position 移动,关于 position 和 anchor point 的关系,推荐一篇我见过的说得最清楚的 博客 。关于这个翻转动作,还有一个小问题,那就是无论你使用哪种方式实现旋转,旋转过程中我们总是会看到视图原来的内容,就像旋转一个印着内容的透明玻璃,这不是我们想要的,一点也不符合显现实中翻转一张卡片的效果。要怎么解决,方法也很简单,继续往下看。
关于卡片的摆放,很多人应该都知道「近大远小」的透视原理,我最早知道这个是在鸟山明的漫画小剧场里看到这种作画技巧来实现在二维平面上实现不同距离的景物的纵深感觉。但注意观察,上面的动画里每张卡片在 X 轴和 Y 轴方向的差距并不是想等的,这个细节很赞。而且要注意,照片的白色边框的宽度也是不一样的,这也和前面这个细节匹配。所以,第二步,我们要设定卡片之间的垂直间距以及水平间距,还有卡片的边框宽度,可以设置一个线性函数来计算这些参数,合适的数值需要通过调试直到达到你的要求为止。第一个动作完成后,后续的卡片依次前进到前面卡片的位置,对后面的卡片依次进行动画就可以,这里主要是 frame(size 和position)以及 borderWidth 的变化。
这个动画里还有不少细节,动画的背景是很深的颜色,而后面的卡片比较暗,非常有真实感。在实现的时候可以减小视图的 alpha 属性,但是 alpha 是透明度,后面的卡片会被透视,相比这里还是很有瑕疵的。我在实现时也试图用渐变色来体现这种感觉,但还是不够好。一个猜想,使用 CALayer 的 mask 属性或许可以达到比较好的效果,但没空试试了。
动画的细节非常重要。
Frame-based Layout Animation
PS: 如果只想了解怎么使用 AutoLayout 来实现效果,恩,还是希望你看看此小节,因为大部分内容是一样的,我也不想再重复内容。基于 frame 实现效果时有些问题没有解决,直到使用 AutoLayout 后才知道问题所在,但是没动力去解决了,等我哪天有心情了再说吧(真不负责啊)。相比使用 AutoLayout 实现的版本,这个版本完成了图中的效果,但没有实现卡片的复用,自适应布局以及代码优化(主要是 AutoLayout 版本改了更好的名字^_^),但没有动力完成了。如果你也还没有使用过 AutoLayout,可以看看此节来过渡一下。
在实现这个动画的前期,我并没有多少使用 AutoLayout 的经验,尽管我开启了 AutoLayout,不过基本上是靠本能来使用(就是没有去学习过 AutoLayout,基本是按照以前的 spring-strut 的经验来使用的)。或者说,实际上我是将 AutoLayout 与 Spring-Strut 模式混合使用的。这也造成了我在实现一些效果时出现的问题无法解决,不过基本完成了效果图中的效果,在重新使用 AutoLayout 来实现这个动画搞懂其中一些问题后对使用 frame-based layout 来实现效果简直有种欧阳峰倒着修炼九阴真经的感觉。
那么先说说基于 frame 来实现动画的过程,实际上和使用 AutoLayout 来实现时大部分关键代码都是通用的,只不过在调整 anchor point 和 frame 时实现方式不同。
在 storyboard 里这里安放视图,在这里使用了内嵌 UIImageView 的 UIView,本来直接使用 UIImageView 也可以,但前面的组合能够破解旋转时的透明背景问题。这里将第一张卡片调整为屏幕居中,400 的宽度,4:3 的长宽比。为了省事,前期我在 storyboard 里直接放了8个同样的视图,只是内容不一样,也没有实现重用。获取卡片视图可以使用UIView.viewWithTag(),按照我的习惯,tag 从1开始计数。对于这种动画,使用手势来操作无疑是最佳选择,而为了使用动画可以交互,使用 pan 手势。不过我也提供了使用 Button 来执行动作。
视图初始时需要调整 storyboard 里的视图以符合卡片的摆放效果。另外,还需要针对翻转动作做一些准备,必须在翻转前保证要翻转的卡片的 anchor point 移动到了卡片的底部,也就是(0.5, 1)。
该怎么调整 frame 以及 anchor point 呢?我最早的实现里,所有卡片都是相同的 frame,采用 transformScale 的方法来调整大小,而 transformScale 是以 anchor point 为中心点缩放的,这种方式还会将 Y 轴上的距离也缩放了,这是我不想要的;旋转卡片需要调整 anchor point 的位置,而为了保持视图的位置不移动,又需要调整 frame 或 point 了。这两者的执行顺序不一样又会造成不同的效果,这在使用 pan 手势执行可交互的翻转动作时又会出现问题,总之是吃力不讨好。后来我还异想天开地在 pan 手势里调整 anchor point,但调整 anchor point 的同时为了保证视图的位置不漂移还得调整 position 或 frame。而调整 frame,需要注意 runloop,而此时 transfrom 在手势里不断变化,这几种加在一起,不会产生你想要的效果。
现在的实现则是初始阶段将所有卡片视图的 frame 调整至需要的值,并调整好 anchor point,最好一切准备,一劳永逸解决各种小毛病。这次的实现,为了省事,没有维护视图的列表,而是直接通过 viewTag 来获取对应视图,这也给复用带来了一点点小问题。
变量设定:
var frontCardTag = 1 //最前面的卡片的 viewTag var cardCount = 8 //我刚开始只是随便设置了这么多,视觉上效果比较好 var originFrame = CGRectZero //保存最前面卡片的 frame,主要是为了应对屏幕方向的变化,便于计算后续卡片的 frame。 var gestureDirection:panScrollDirection = .Up //记录 pan 手势的起始方向
frame 以及 anchorPoint 调整:
除了这些,还需要一些辅助函数,根据卡片在屏幕上的相对位置来计算与前一张卡片在 Y 轴上的间距,在 X 轴方向尺寸的缩小比例,以及 alpha 的设定。前期,我将 borderWidth 的值设定为 5*scale,但后期发现使用1/100的 width 值在视觉上更舒服。再次说明一次,卡片间的垂直距离是依次递减的,卡片的尺寸和边框宽度的缩放比例也是依次递减的,为了不那么复杂,都是线性递减的,具体实现可以看代码。这些不是此次的重点,随你自己喜好设定就行。
参数设定-啊,我的字真丑
做完了这些准备,先来实现简单一点的操作:点击按钮后执行翻转操作以及移动后面的卡片到前面卡片的位置。
向下翻动卡片:
@IBAction func flipDown(sender: AnyObject) { //边界判定 if frontCardTag > cardCount{ return } guard let frontView = view.viewWithTag(frontCardTag) else{ return } var flipDownTransform3D = CATransform3DIdentity //m34这个值用来表示视觉上焦点的位置,不明白的话,只需要知道设置的值越大相当于卡片离你的距离越远, //而此时看到的翻转效果就不会产生你讨厌的侧边幅度过大的问题。 flipDownTransform3D.m34 = -1.0 / 1000.0 //此处有个很大的问题,折磨了我几个小时。原来官方的实现有个临界问题,旋转180度不会执行,直接跳转,其他的角度则没有问题。 //而在手势里却没有问题,可能在手势里和 button action 的运行机制不一样。 flipDownTransform3D = CATransform3DRotate(flipDownTransform3D, CGFloat(-M_PI) * 0.99, 1, 0, 0) UIView.animateWithDuration(0.3, animations: { frontView.layer.transform = flipDownTransform3D }, completion: { _ in frontView.hidden = true self.adjustDownViewLayout() }) } //将后面的卡片依次移动到前面并设定新的 frame,borderWidth(其实就是使用前面卡片的设定) func adjustDownViewLayout(){ frontCardTag += 1 if frontCardTag <= cardCount{ for viewTag in frontCardTag...cardCount{ if let subView = view.viewWithTag(viewTag){ //delay 时间的间隔可以实现不同的视觉效果,同步移动还是异步移动,看你的需要了 let delay: NSTimeInterval = 0.1 * Double(viewTag - frontCardTag) UIView.animateWithDuration(0.3, delay: delay, options: UIViewAnimationOptions.CurveEaseIn, animations: { let (frame, borderWidth) = self.calculateFrameAndBorderWidth(viewTag - self.frontCardTag, initialBorderWidth: 5) subView.frame = frame subView.layer.borderWidth = borderWidth }, completion: nil) } } } }
向上恢复卡片:
@IBAction func flipUp(sender: AnyObject) { if frontCardTag == 1{ return } guard let previousFrontView = view.viewWithTag(frontCardTag - 1) else{ return } var flipUpTransform3D = CATransform3DIdentity flipUpTransform3D.m34 = -1.0 / 1000.0 flipUpTransform3D = CATransform3DRotate(flipUpTransform3D, 0, 1, 0, 0) UIView.animateWithDuration(0.3, animations: { previousFrontView.hidden = false previousFrontView.layer.transform = flipUpTransform3D }, completion: { _ in self.adjustUpViewLayout() }) } func adjustUpViewLayout(){ if frontCardTag >= 2{ //代码里我弄了两种效果,一个从前往后,一个从后往前 for var viewTag = frontCardTag; viewTag <= cardCount; ++viewTag{ if let subView = view.viewWithTag(viewTag){ let relativeIndex = viewTag - self.frontCardTag + 1 let delay: NSTimeInterval = Double(viewTag - frontCardTag) * 0.1 UIView.animateWithDuration(0.2, delay: delay, options: UIViewAnimationOptions.BeginFromCurrentState, animations: { let (frame, borderWidth) = self.calculateFrameAndBorderWidth(relativeIndex, initialBorderWidth: 5) subView.frame = frame subView.layer.borderWidth = borderWidth }, completion: nil) } } frontCardTag -= 1 } }
交互动画
在 pan 手势执行的代码里,很多参数并不是我开始就知道的,需要不断调试来判断如何使得角度与进度配合得到预期的效果。
在 pan 手势里,根据手势在屏幕上移动的距离来判断进度:
let percent = gesture.translationInView(view).y/150 //y 值可以为负,因此进度也会是负值
在手势的开始阶段根据速度的正负来判断执行的操作。
case .Began: if velocity.y > 0{ //向下翻转卡片 gestureDirection = .Down }else{ //将下方的卡片翻回上面 gestureDirection = .Up }
在手势的变化阶段,需要将动画过程交互化,调整翻转的角度与进度匹配,需要注意边界条件的判定。
翻转卡片时,当卡片与屏幕垂直,继续翻转时,此时卡片背面应该无法看见卡片正面的内容,然而 iOS 提供的所有翻转方式里视图层都是透明的,刚开始我想在此时添加背景视图来覆盖卡片的内容,然而此时出现了卡片的位置偏移的问题,百思不得其解。一计不成,再想一计。在 storyboard 里设置卡片背景颜色为需要的颜色,当卡片与屏幕垂直继续翻转时将图片视图隐藏,Bingo,同时,完善细节,将 borderWidth 修改为0。而前面的方案 bug 的关键在于代码生成 UIView 实例时,translatesAutoresizingMaskIntoConstraints属性默认为true,而这将视图的 resize mask 与 AutoLayout 混合,造成了这个 bug。而这个问题在我学习 AutoLayout 时才搞清楚。不过即使搞清楚也没办法解决这个问题,因为两者的混合总是会带来一些意想不到的问题,还是使用第二种方案比较好。
case .Change: /... do other thing ../ if percent >= 0.5{ if let subView = frontView?.viewWithTag(10){ subView.hidden = true frontView?.layer.borderWidth = 0 } }else{ if let subView = frontView?.viewWithTag(10){ subView.hidden = false frontView?.layer.borderWidth = 5 } }
pan 手势方法的完整实现:
func scrollOnView(gesture: UIPanGestureRecognizer){ //临界条件的判断 if frontCardTag > cardCount + 1{ frontCardTag -= 1 return } if frontCardTag < 1{ frontCardTag += 1 return } let frontView = view.viewWithTag(frontCardTag) let previousFrontView = view.viewWithTag(frontCardTag - 1) let velocity = gesture.velocityInView(view) let percent = gesture.translationInView(view).y/150 var flipTransform3D = CATransform3DIdentity flipTransform3D.m34 = -1.0 / 1000.0 switch gesture.state{ //手势的开始阶段判断向上翻动还是向下翻动卡片 case .Began: if velocity.y > 0{ gestureDirection = .Down }else{ gestureDirection = .Up } case .Changed: if gestureDirection == .Down{ switch percent{ case 0.0..= 0.5{ if let subView = frontView?.viewWithTag(10){ subView.hidden = true frontView?.layer.borderWidth = 0 } }else{ if let subView = frontView?.viewWithTag(10){ subView.hidden = false frontView?.layer.borderWidth = 5 } } case 1.0...CGFloat(MAXFLOAT): flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI), 1, 0, 0) frontView?.layer.transform = flipTransform3D default: print(percent) } } else { if frontCardTag == 1{ return } previousFrontView?.hidden = false switch percent{ case CGFloat(-MAXFLOAT)...(-1.0): previousFrontView?.layer.transform = CATransform3DIdentity case -1.0...0: if percent = 0.5{ flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(M_PI), 1, 0, 0) UIView.animateWithDuration(0.3, animations: { frontView?.layer.transform = flipTransform3D }, completion: { _ in frontView?.hidden = true if frontView != nil{ self.adjustDownViewLayout() } }) }else{ //不然就原路返回,取消翻转 UIView.animateWithDuration(0.2, animations: { frontView?.layer.transform = CATransform3DIdentity }) } case .Up: if frontCardTag == 1{ return } if percent <= -0.5{ UIView.animateWithDuration(0.2, animations: { previousFrontView?.layer.transform = CATransform3DIdentity }, completion: { _ in self.adjustUpViewLayout() }) }else{ UIView.animateWithDuration(0.2, animations: { previousFrontView?.layer.transform = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI), 1, 0, 0) }, completion: { _ in previousFrontView?.hidden = true }) } } default: print("DEFAULT: DO NOTHING") } }
Frame-Based Layout 的隐患
到这里为止,遇到的大部分问题都被解决了。直到我试图在代码里添加新的卡片,但总是会导致其他卡片发生漂移。这时候我还没有实现复用机制,而添加新卡片总是需要的,不能避开这个问题,但用尽了我知道的方法依然无法解决,这时候我意识到可能需要换个方向了。
事实上这个问题也很简单,在代码里添加 UIView 时,切记将translatesAutoresizingMaskIntoConstraints属性值修改为false。这个属性用来决定是否将 frame 驱动的 Spring-Strut 布局模式与 AutoLayout 模式混合,值为 true 时,则将传统的 frame 与 AutoLayout 结合,你可以直接修改 frame,AutoLayout 的约束机制会自动将这个变化转变为约束。在 storyboard 里生成的 UIView 的这个属性默认为 false,在代码里生成的 UIView 的这个属性默认为 true。听起来非常美好吧,这是来自今年的 WWDC 的高级技巧: Mysteries of Auto Layout, Part 2 ,在视频下方的搜索里输入该属性,会列出视频里提到该词的地方,这是今年苹果为开发者出的一个非常有用的功能。这个视频首先是从 How I Learned to Stop Worrying and Love Cocoa Auto Layout 这篇文章里知晓的,这篇文章列举了 AutoLayout 的一些非常重要的优点和缺点,非常值得一读。
然而我的实践却正好相反:在上面的实现里,我在不知晓 AutoLayout 的情况下,直接更改 frame 来执行动画,而此时该属性值为 false,不应该如此,然而 frame 和约束就这样和谐地配合了;然而从代码添加卡片时导致了其他卡片的位置移动,此时这些属性值为 true,那么这个行为也不该如此呀。而我尝试手动将 storyboard 里获取的视图的该属性值设定为 true 时,又出现了一系列的约束冲突。显然,苹果的工程师不会在 WWDC 里犯这种错误,那么肯定是我修行不够,哪儿有点纰漏。等日后我搞明白了再更新。
总之,我稀里糊涂地将两种模式混合使用,然后在一个不起眼的小地方翻了船,不得不寻求他法。换一种全新的方式来实现,还得重新学,听说还很复杂? How I Learned to Stop Worrying and Love Cocoa Auto Layout 这篇文章里列举的使用 AutoLayout 可能会遇到的麻烦这个动画恰好就占了最重要的那一条:anchor point,transform 与 Autolayout 不和。
好好休息一下,泡杯咖啡,提升一下决心,再来学习 AutoLayout 吧。 官方文档 以及 raywenderlich.com 关于 AutoLayout 的两篇文章 Part I 和 Part II 都是极好的入门指南,后者还指出了传统的 Spring-Strut 布局方案的局限,建议先读后者再看前者,前者比较全但不能满足你急切解决问题的心情,后者也不能解决今天的这个问题,但是能让你快速了解 AutoLayout 便于进入状态。
AutoLayout Animation
其实在上面的小节里已经将所有的问题解决了,但是那是在学习了 AutoLayout 后才知道的马后炮解决手段。AutoLayout 应该很好学,毕竟我之前都是靠着本能来使用的,只不过不太了解与 AutoLayout 直接打交道的具体手法,不过手动调整约束的代码比起修改 frame ,两者对程序员的吸引程度似乎不是一个纬度,大大打消学习热情。
AutoLayout 科普入门(非小白可跳过)
首先,总结一下,传统的 Spring-Strut 布局方案与 AutoLayout 布局方案的差异,为什么苹果抛弃了前者?raywenderlich.com 家的文章说得很清楚,Spring-Strut 描述了 superView 与 subView 之间的布局关系,但缺乏对平行的 subView 间的布局描述,AutoLayout 补上了这个缺。那为何不直接将前者改造成后者或者把框架名字换一下呢?底层实现不清楚,不瞎猜了。
其次,使用 AutoLayout 要抛弃之前 frame 的概念,改而使用约束 constraint。在 AutoLayout 的世界里,视图的位置和大小都由附在视图上的约束来决定,这个过程很像我们针对视图的尺寸和位置等数据设置了一堆方程式来交给 AutoLayout 来运算。如果这堆方程式是可解的,那么视图的布局就是确定的;如果方程无解,就会发生冲突,你将在控制台看见一大堆的报告;如果方程式条件不足,AutoLayout 无法给出唯一解,就没法确定视图的布局。
而 AutoLayout 里决定布局不止 constraint,看上图,还有约束的 priority,以及视图本身的固有尺寸 intrinsicContentSize,其实看名字就很好理解了。在这个动画这里,可以只考虑约束就可以完成动画了,这也是从 frame-based layout 转变到 AutoLayout 最无痛的方式了。
比如,某个视图的 frame 为(100, 300, 400, 300), 向移动100个单位:
let oldFrame = subView.frame let newFrame = CGRectMake(oldFrame.origin.x + 100, oldFrame.origin.y, oldFrame.size.width, oldFrame.size.height) UIView.animateWithDuration(0.3, { subView.frame = newFrame })
那么使用 AutoLayout 怎么实现这个动画?首先我们要改用约束来描述该视图的布局。约束条件非常灵活,可以有多种方案,最简单的一种,这里对于视图在 X 方向的位置约束可以描述为视图的左侧 leading 距离父视图的leading 距离为100单位,现在要将这个距离修改为200单位。配合稍微有点不搭,凑合看看。
官方对 constraint 的图解
约束使用NSLayoutConstraint类,刚开始看着头疼,多写写就习惯了。不过,这里有个地方要注意,约束描述了视图和其他视图的关系,一般都是双向的,UIView 的 constraints 里保存了视图的约束,那怎么找到我们需要的约束呢,双向关系的约束保存在哪里,双方都有一份吗?记住,视图只保存自己与自身子视图之间的约束以及自身子视图之间的约束。那么上面视图的约束就保存在父视图的约束里,找出来修改:
for constraint in superView.constraints{ if constraint.firstItem == subView && constraint.secondItem == superView && constraint.firstAttribute == .CenterX{ constraint.constant = 200 break } } //或者使用 filter 功能 let centerXConstraint = superView.constraints.filter({$0.firstItem as? UIView == subView && $0.secondItem as? UIView == superView && $0.firstAttribute == .CenterX})[0] centerXConstraint.constant = 200 //修改约束后,要求父视图重新布局。虽然上面的修改本身是即时的,但需要这样才能用动画表现 UIView.animateWithDuration(0.3, { superView.layoutIfNeeded() })
这样看起来,似乎要比 frame 动画麻烦好多啊,的确是这样。不过,对于卡片动画中调整各卡片距离时,AutoLayout 实现可以简单得多:其他卡片添加对前面一张卡片的距离约束,修改第一张卡片的位置约束,就能自动调整其他卡片的位置,如果用 frame 来实现,得去修改每一张卡片的 frame。不过在这次的 AutoLayout 实现里,我没有选择这么做,还是选用 frame 的策略,修改每一张卡片相对父视图 centerY的约束。为何?因为,前面的卡片可能会被移除出视图,这样约束也会随之消失,或者前面的卡片会被重用而修改约束,此时两者之间的约束关系就需要发生变化。那么,全部针对父视图的 centerY 添加约束,虽然麻烦需要逐个修改,但这个约束条件就稳定多了。
这里有个例子,修改约束的 Priority 来执行动画,AutoLayout 的确是很灵活,也大大增加了复杂性,我到现在还是很难摈弃原来的 frame 的思维方式,大部分时候还是将 frame 动画重新用约束来写罢了。那么基本的 AutoLayout 动画会了,接下来,解决最大的难点:anchor point.
视图的 anchor point 是视图进行缩放,移动,旋转的中心点,实际上它是视图的 position 在自身坐标系的投影,对于两者的关系,依然推荐这篇 博客 。那么在 AutoLayout 中,怎么调整 anchor point 呢? statckoverflow 上两年前就讨论这问题了 ,下面的回答里有第一个高票回答非常精彩,还顺带回答了 transform 与 AutoLayout 的问题,这又是下一个难点,不过似乎有点跑题了,没有直接回答调整 anchor point 的问题。也许是因为问题是两年前的,AutoLayout 也进化了两年了,可能当初的问题现在被解决了。根据回答,iOS 7 里 transform 与 AutoLayout 不怎么和睦,两者的结合通常不会有好结果,直到 iOS 8 才和谐起来,著名的界面调试软件 reveal 的博客里就有这么 一篇文章 Constraints & Transformations 讲述了 iOS 8 里两者是怎么愉快相处的。我也扯远了。那个高票回答里提出一种解决方案,将要调整 anchor point 和要旋转的视图内嵌在容器视图里,在容器视图内调整 anchor point 和旋转,一举两得。然而,我还没来得及实验这种方法就已经找到另外一种方法。
不过首先,得先转换到 AutoLayout 的环境下,这时候不能像 frame-based layout 那样设定约束了。实际上大部分还是相同的,只不过在使用时会修改一些我以前不知道的地方罢了。所有视图依然居中,还记得上一节那个配图中的约束公式吗,那个常量值为0,以前我直接修改 frame,现在修改这个常量值就可以达到同样的目的;宽高比依然设定为4:3,宽度设定为400,在布局时修改常量值修改宽度,而高度则由 AutoLayout 引擎计算出来,不像之前直接设定长宽数值,其实之前也可以直接修改约束,但我不知道可以修改。除了还要设定内嵌的图像视图的约束,这就完了。
重新设定约束
通常我们这样调整 anchor point 让视图不发生漂移:
subView.frame = frame subView.layer.anchorPoint = CGPointMake(0.5, 1) subView.frame = frame
事实上,用 constraint 的方式来实现这个手法就可以解决这个问题了:修改 anchor point 后,视图的位置发生了移动,那么补偿这段移动就可以了。具体的计算方法可能要根据约束的条件来决定,这点不如 frame 时的简单。不过,解决了不是。代码里用于初始化配置的函数实现了模块优化和改名优化^_^,可能和上面的对不上号。
let centerYConstraint = superView.constraints.filter({$0.firstItem as? UIView == subView && $0.secondItem as? UIView == superView && $0.firstAttribute == .CenterY}) let subViewHeight = .... let oldConstraintConstant = centerYConstraint.constant subView.layer.anchorPoint = CGPointMake(0.5, 1) //关键代码:anchor point从(0.5,0.5)->(0.5,1),视图会往上移动自身高度的一半,那么补偿这段高度 centerYConstraint.constant = subViewHeight/2 + oldConstraintConstant
这样就解决了所有问题了。对了,transform 的问题不用解决,代码中的其他改动也只是模块化后的变动。
小总结
这次动画的最大难点在于调整 anchor point,搞清楚机制后这个问题就很简单了。 对于使用 frame 还是 AutoLayout,后者无疑是适应性布局的首选,虽然复杂了一些,坑也有不少,但值得入坑。
AutoLayout 不断在改进,所以一些老问题就消失了,transform 的问题就是。实际上 transform 跟 AutoLayout 没有交集,AutoLayout 只对约束有效,transform 并没有修改约束条件,两者互不干扰。而 transform 跟 frame 的关系也很有意思,transform 对视图的 bounds 和 center 两个属性并没有影响,只对 frame 有影响,自己可以在代码中验证一下。AutoLayout 和 frame,使用前者时最好不要直接修改 frame,虽然也能按照你的意愿工作,但指不定不注意就掉坑里了。
起初实现的时候没有考虑那么多,这类动画还会有重排序、删除和添加卡片的需求,后续有空会尝试把这几个功能补上,另外,有时间的话会考虑做成提供数据源后一键使用的样子。
彻底理解 position 与 anchorPoint
How I Learned to Stop Worrying and Love Cocoa Auto Layout
Auto Layout Guide
WWDC15 Session 219: Mysteries of Auto Layout, Part 1
WWDC15 Session 219: Mysteries of Auto Layout, Part 2
Auto Layout Tutorial in iOS 9 Part 1: Getting Started
Auto Layout Tutorial in iOS 9 Part 2: Constraints
stackoverflow: How do I adjust the anchor point of a CALayer, when Auto Layout is being used?
Constraints & Transformations: How Auto Layout quietly became transform-friendly in iOS 8