分享

卡片动画 Card Animation

 wintelsui 2015-10-29

多图预警,手机查看注意流量!今天要实现这个动画,来自 Dribbble


Card Animation.gif


实际效果:


Card Animation.gif

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

源代码:https://github.com/seedante/CardAnimation.git
关键词:transform, anchorPoint, Frame Based Layout, Auto Layout
看点总结:实践了基本的 transform 动画,趟过代码中建立视图与 Auto Layout 配合的坑,解决了旋转时动画视图背景透明以及 使用 Auto Layout 时调整 anchorPoint 的难题。

动画分析

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


Core Animation Rotation - iOS Core Animation Advanced Techniques

这里是沿着 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)

然后这里的旋转不是沿着默认的中心点旋转的,而是视图的底部,这意味着我们需要调整 anchorPoint 为(0.5, 1),然而我们都知道调整 anchorPoint 后会导致视图的 position 移动,关于 position 和 anchorPoint 的关系,推荐一篇我见过的说得最清楚的博客。关于这个翻转动作,还有一个小问题,那就是无论你使用哪种方式实现旋转,旋转过程中我们总是会看到视图原来的内容,就像旋转一个印着内容的透明玻璃,这不是我们想要的,一点也不符合显现实中翻转一张卡片的效果。要怎么解决,方法也很简单,继续往下看。

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

这个动画里还有不少细节,动画的背景是很深的颜色,而后面的卡片比较暗,非常有真实感。在实现的时候可以减小视图的 alpha 属性,但是 alpha 是透明度,后面的卡片会被透视,相比这里还是很有瑕疵的。我在实现时也试图用渐变色来体现这种感觉,但还是不够好。一个猜想,使用 CALayer 的 mask 属性或许可以达到比较好的效果,但没空试试了。

动画的细节非常重要。

Frame Based Layout Animation

PS: 如果只想了解怎么使用 Auto Layout 来实现效果,恩,还是希望你看看此小节,因为大部分内容是一样的,我也不想再重复内容。基于 frame 实现效果时有些问题没有解决,直到使用 Auto Layout 后才知道问题所在,但是没动力去解决了,等我哪天有心情了再说吧(真不负责啊)。相比使用 Auto Layout 实现的版本,这个版本完成了图中的效果,但没有实现自适应布局以及代码优化(主要是 AutoLayout 版本改了更好的名字^_^),但没有动力完成了。如果你也还没有使用过 Auto Layout,可以看看此节来过渡一下。

在实现这个动画的前期,我并没有多少使用 Auto Layout 的经验,尽管我开启了 Auto Layout,不过基本上是靠本能来使用(就是没有去学习过 Auto Layout,基本是按照以前的 Spring-Strut 布局模式也就是 autoresize mask 的经验来使用的)。或者说,实际上我是将 Auto Layout 与 Spring-Strut 模式混合使用的。这也造成了我在实现一些效果时出现的问题无法解决,不过基本完成了效果图中的效果,在重新使用 Auto Layout 来实现这个动画搞懂其中一些问题后对使用 Frame Based Layout 来实现效果简直有种欧阳峰倒着修炼九阴真经的感觉。

那么先说说基于 frame 来实现动画的过程,实际上和使用 Auto Layout 来实现时大部分关键代码都是通用的,只不过在调整 anchorPoint 和 frame 时实现方式不同。

动画准备

在 storyboard 里这里安放视图,在这里使用了内嵌 UIImageView 的 UIView,本来直接使用 UIImageView 也可以,但前面的组合能够破解旋转时的透明背景问题。这里将第一张卡片调整为屏幕居中,也就是添加卡片视图的 centerX 和 centerY 与父视图重合的约束,400 的宽度,4:3 的长宽比。为了省事,前期我在 storyboard 里直接放了8个同样的视图,通过 viewTag 来区分,获取卡片视图可以使用UIView.viewWithTag(),按照我的习惯,tag 从1开始计数。对于这种动画,使用手势来操作无疑是最佳选择,而为了使用动画可以交互,使用 pan 手势。不过我也提供了使用 Button 来执行动作。



视图出现时需要调整 storyboard 里的视图以符合卡片的摆放效果。另外,还需要针对翻转动作做一些准备,必须在翻转前保证要翻转的卡片的 anchorPoint 移动到了卡片的底部,也就是(0.5, 1)

该怎么调整 frame 以及 anchorPoint 以及在什么地方调整比较合适呢?我最早的实现里,所有卡片都是相同的 frame,在viewDidLoad()调整 frame,然后采用 transformScale 的方法来调整大小,但这种方式会将 Y 轴上的间距也缩放了,比如你设定两个卡片 Y 轴上的间距为10,缩放后这个间距也按卡片本身的缩放比例缩小了,这是我不想要的;这里旋转卡片需要调整 anchorPoint ,从(0.5, 0.5)调整到(0.5, 1),不出意外的话,卡片的位置会移动,而为了保持视图的位置不移动,又需要调整 frame 了。这两者的执行顺序不一样又会造成不同的效果,这在使用 pan 手势执行可交互的翻转动作时又会出现问题,总之是吃力不讨好。后来我还异想天开地在 pan 手势里调整 anchorPoint,但这里真不是个合适的地方,调整 anchorPoint 的同时要调整 frame,而你只需要调整一次,但手势的方法是一直在被重复调用的,而此时 transfrom 在手势里不断变化,这个会影响 frame,这几种加在一起,不会产生你想要的效果。

现在的实现则是初始阶段将所有卡片视图的 frame 调整至需要的值,并调整好 anchorPoint,做好一切准备,就等在 pan 手势里旋转了,一劳永逸解决各种小毛病。早期为了省事,没有实现重用,而是直接通过 viewTag 来获取对应视图,这也给复用带来了一点点小问题。

变量设定和初始配置:

var frontCardTag = 1 //最前面的卡片的 viewTag
var cardCount = 8 //我刚开始只是随便设置了这么多,视觉上效果比较好
var originFrame = CGRectZero //保存最前面卡片的 frame,主要是为了应对屏幕方向的变化,便于计算后续卡片的 frame。
var gestureDirection:panScrollDirection = .Up //记录 pan 手势的起始方向

override func viewDidLoad() {
    super.viewDidLoad()
    //添加 pan 手势支持
    let scrollGesture = UIPanGestureRecognizer(target: self, action: "scrollOnView:")
    view.addGestureRecognizer(scrollGesture)
    //由于 runloop 的缘故,这里必须使用 performSelector 方法来执行使得初始画面是正常的。
    //使用 Auto Layout 时,则不需要这么做。
    self.performSelector("resetViewLayout:", withObject: nil, afterDelay: 0.1)
}

调整 frame 以及 anchorPoint,在初次加载视图和方向旋转时调用该方法。

func resetViewLayout(originFrameValue: NSValue?){
    //使用 frame 来实现效果必须记录第一张卡片的原始 frame,以便于计算后续卡片的位置和大小。同时在旋转屏幕方向时需要更新该值。
    var baseFrame = CGRectZero
    if originFrameValue != nil{
        baseFrame = (originFrameValue?.CGRectValue())!
    }else{
        let frontView = view.viewWithTag(frontCardTag)!
        originFrame = frontView.frame
        baseFrame = originFrame
    }

    //调整可见的卡片
    if frontCardTag <= cardCount{
        for viewTag in frontCardTag...cardCount{
            if let subView = view.viewWithTag(viewTag){
                let relativeIndex = viewTag - frontCardTag
                let delay: NSTimeInterval = Double(relativeIndex) * 0.05
                UIView.animateWithDuration(0.3, delay: delay, options: UIViewAnimationOptions.CurveEaseOut, animations: {
                    //翻下去的卡片在屏幕中隐藏了,为了避免 bug,这里必须保证可见
                    subView.hidden = false
                    //不同位置的 frame 和 borderWidth 是根据卡片相对最前面卡片的位置而决定的,具体的计算方法可以看代码
                    let (frame, borderWidth) = self.calculateFrameAndBorderWidth(relativeIndex, initialBorderWidth: 5)
                    //调整 anchor point,并且保持视图位置不漂移。
                    subView.frame = frame
                    subView.layer.anchorPoint = CGPointMake(0.5, 1)
                    subView.frame = frame
                    //调整视图在 Z 轴上的位置,数值越大在视觉上越靠前,数值为负数时,就在屏幕后面去了,也就是不可见了。这个设定也不是必要的。
                    subView.layer.zPosition = CGFloat(1000-viewTag)
                    //设置卡片的边框,其宽度也是递减的
                    subView.layer.borderWidth = borderWidth
                    subView.layer.borderColor = UIColor.whiteColor().CGColor
                    }, completion: nil)
            }
        }
    }

    //旋转屏幕方向时,必须调整这些已经翻下去的卡片的 frame,不然当重新翻上去的时候出现的位置就不对了。
    //这里引发一个疑问,就是 transform 对 frame 有什么影响,从这点来看,视图本身的形态由 frame 和 transform 决定,但 transfrom 并不影响 frame 值。
    if frontCardTag > 1{
        for viewTag in 1..<frontCardTag{
            if let subView = view.viewWithTag(viewTag){
                subView.frame = baseFrame
            }
        }
    }
}

应对屏幕方向变化:

//在 iOS 8 以后的版本中,应对屏幕方向的改变推荐使用这个方法,来自http:///questions/26069874/what-is-the-right-way-to-handle-orientation-changes-in-ios-8
override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator coordinator: UIViewControllerTransitionCoordinator) {
    super.viewWillTransitionToSize(size, withTransitionCoordinator: coordinator)
    coordinator.animateAlongsideTransition({
        _ in
        self.gradientBackgroundLayer.frame = self.view.bounds
        let screenRect = UIScreen.mainScreen().bounds
        let originX = (screenRect.size.width - 400)/2
        let originY = (screenRect.size.height - 300)/2
        //旋转屏幕时更新 originFrame
        self.originFrame = CGRectMake(originX, originY, 400, 300)
        self.resetViewLayout(NSValue.init(CGRect: self.originFrame))
        }, completion: nil)
}

除了这些,还需要一些辅助函数,根据卡片在屏幕上的相对位置来计算与前一张卡片在 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
}

在手势的变化阶段,需要将动画过程交互化,调整翻转的角度与进度匹配,需要注意边界条件的判定。

//向下翻卡片,0<percent<1
flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI) * percent, 1, 0, 0)
frontView?.layer.transform = flipTransform3D
//向上翻卡片, -1<percent<0
flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI) * (percent + 1.0), 1, 0, 0)
previousFrontView?.layer.transform = flipTransform3D

翻转卡片时,当卡片与屏幕垂直,继续翻转时,此时应该只能看到卡片的背面,卡片正面的内容被遮挡了,然而 iOS 提供的所有翻转方式里视图层都是透明的,刚开始我想在此时添加背景视图来覆盖卡片的内容,然而此时出现了卡片的位置偏移的问题,百思不得其解。一计不成,再想一计。在 storyboard 里设置卡片背景颜色为需要的颜色,当卡片与屏幕垂直继续翻转时将图片视图隐藏,Bingo,同时,完善细节,将 borderWidth 修改为0。而前面的方案 bug 的关键在于代码生成 UIView 实例时,translatesAutoresizingMaskIntoConstraints属性默认为true,而这将视图的 autoresize mask 与 Auto Layout 混合,造成了这个 bug。而这个问题在我学习 Auto Layout 时才搞清楚。不过即使搞清楚也没办法解决这个问题,因为两者的混合总是会带来一些意想不到的问题,我目前还搞不大清除问题的由来,还是只使用Auto Layout 不那么容易出问题。

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..<1.0:
                flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI) * percent, 1, 0, 0)
                frontView?.layer.transform = flipTransform3D
                //当卡片与屏幕垂直时,将图片隐藏,此时将只会看到视图的背景色,同时要注意调整 borderWidth。
                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
                    }
                }
            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{
                    if let subView = previousFrontView?.viewWithTag(10){
                        subView.hidden = false
                        previousFrontView?.layer.borderWidth = 5
                    }
                }else{
                    if let subView = previousFrontView?.viewWithTag(10){
                        subView.hidden = true
                        previousFrontView?.layer.borderWidth = 0
                    }
                }
                flipTransform3D = CATransform3DRotate(flipTransform3D, CGFloat(-M_PI) * (percent + 1.0), 1, 0, 0)
                previousFrontView?.layer.transform = flipTransform3D
            default:
                print(percent)
            }
        }
    case .Ended:
        switch gestureDirection{
        case .Down:
            //翻转程度达到一半时自动完成这个翻转
            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 布局模式也就是 autoresize mask 与 AutoLayout 模式混合,值为 true 时,两者结合使用,你可以直接修改 frame,Auto Layout 的约束机制会自动将这个变化转变为约束。在 storyboard 里生成的 UIView 的这个属性默认为 false,在代码里生成的 UIView 的这个属性默认为 true。听起来非常美好吧,这是来自今年的 WWDC 的高级技巧:Mysteries of Auto Layout, Part 2,在视频下方的搜索里输入该属性,会列出视频里提到该词的地方,这是今年苹果为开发者出的一个非常有用的功能。这个视频首先是从 How I Learned to Stop Worrying and Love Cocoa Auto Layout 这篇文章里知晓的,这篇文章列举了 Auto Layout 的一些非常重要的优点和缺点,非常值得一读。

然而我的实践却正好相反:在上面的实现里,我在不知晓 Auto Layout 的情况下,直接更改 frame 来执行动画,而此时该属性值为 false,不应该如此,然而 frame 和约束就这样和谐地配合了;然而从代码添加卡片时导致了其他卡片的位置移动,此时这些属性值为 true,那么这个行为也不该如此呀。而我尝试手动将 storyboard 里获取的视图的该属性值设定为 true 时,又出现了一系列的约束与 autoresize mask 的冲突。显然,苹果的工程师不会在 WWDC 里犯这种错误,那么肯定是我修行不够,哪儿有点纰漏,等日后我搞明白了再更新。

总之,我稀里糊涂地将两种模式混合使用,然后在一个不起眼的小地方翻了船,不得不寻求他法。换一种全新的方式来实现,还得重新学,听说还很复杂? How I Learned to Stop Worrying and Love Cocoa Auto Layout 这篇文章里列举的使用 Auto Layout 可能会遇到的麻烦这个动画恰好就占了最重要的那一条:anchorPoint,transform 与 Autolayout 不和。



好好休息一下,泡杯咖啡,提升一下决心,再来学习 Auto Layout 吧。官方文档以及 关于 AutoLayout 的两篇文章 Part IPart II 都是极好的入门指南,后者还指出了传统的 Spring-Strut 布局方案的局限,建议先读后者再看前者,前者比较全但不能满足你急切解决问题的心情,后者也不能解决今天的这个问题,但是能让你快速了解 Auto Layout 便于进入状态。

Auto Layout Animation

其实在上面的小节里已经将所有的问题解决了,但是那是在学习了 Auto Layout 后才知道的马后炮解决手段。Auto Layout 应该很好学,毕竟我之前都是靠着本能来使用的,只不过不太了解与 Auto Layout 直接打交道的具体手法。实际情况是 Auto Layout 上手容易,精通难,太灵活,也就意味着太复杂,而且手写约束让人望而生畏。Github 上有好些简化约束写法的库:SnapKit, Masonry, Cartography。我的看法是,在初期,还是试着用最原始的方法写,等熟悉了 constraint,最来使用这些提升效率的库也不迟。除此之外,苹果还提供了一种可视化格式语言 Visual Format Language 来方便添加约束,不过这些对于刚开始使用 Auto Layout 的人来说的确有点,恩,走出舒适区总是不那么情愿的。Don't be panic,这些东西还是很有趣的。苹果也不断在提升在代码里添加约束的体验,去看看今年 WWDC 关于 Auto Layout 的视频就知道了。

AutoLayout 科普入门(非小白可跳过)

首先,总结一下,传统的 Spring-Strut 布局方案与 Auto Layout 布局方案的差异,为什么苹果抛弃了前者? 家的文章说得很清楚,Spring-Strut 描述了 superView 与 subView 之间的布局关系,但缺乏对平行的 subView 间的布局描述,Auto Layout 补上了这个缺。那为何不直接将前者改造成后者或者把框架名字换一下呢?底层实现不清楚,不瞎猜了。

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



而 Auto Layout 里决定布局不止 constraint,看上图,还有约束的 priority,以及视图本身的固有尺寸 intrinsicContentSize,其实看名字就很好理解了。在这个动画这里,可以只考虑约束就可以完成动画了,这也是从 Frame Based Layout 转变到 Auto Layout 最无痛的方式了。

比如,某个视图的 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
})

那么使用 Auto Layout 怎么实现这个动画?首先我们要改用约束来描述该视图的布局。约束条件非常灵活,可以有多种方案,最简单的一种,这里对于视图在 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 动画麻烦好多啊,的确是这样。不过,对于卡片动画中调整各卡片距离时,Auto Layout 实现可以简单得多:其他卡片添加对前面一张卡片的距离约束,修改第一张卡片的位置约束,就能自动调整其他卡片的位置,如果用 frame 来实现,得去修改每一张卡片的 frame。不过在这次的 Auto Layout 实现里,我没有选择这么做,还是选用 frame 的策略,修改每一张卡片相对父视图 centerY的约束。为何?因为,前面的卡片可能会被移除出视图,这样约束也会随之消失,或者前面的卡片会被重用而修改约束,此时两者之间的约束关系就需要发生变化。那么,全部针对父视图的 centerY 添加约束,虽然麻烦需要逐个修改,但这个约束条件就稳定多了。

这里有个例子,修改约束的 priority 来执行动画,Auto Layout 的确是很灵活,也大大增加了复杂性,我到现在还是很难摈弃原来的 frame 的思维方式,大部分时候还是将 frame 动画重新用约束来写罢了。那么基本的 Auto Layout 动画会了,接下来,解决最大的难点:anchorPoint.

Auto Layout And AnchorPoint

视图的 anchorPoint 是视图进行缩放,移动,旋转的中心点,实际上它是视图的 position 在自身坐标系的投影,对于两者的关系,依然推荐这篇博客。那么在 Auto Layout 中,怎么调整 anchorPoint 呢?statckoverflow 上两年前就讨论这问题了,下面的回答里有第一个高票回答非常精彩,还顺带回答了 transform 与 Auto Layout 的问题,这又是下一个难点,不过似乎有点跑题了,没有直接回答调整 anchorPoint 的问题。也许是因为问题是两年前的,Auto Layout 也进化了两年了,当初的问题现在被解决了。根据回答,iOS 7 里 transform 与 Auto Layout 不怎么和睦,两者的结合通常不会有好结果,直到 iOS 8 才和谐起来,著名的界面调试软件 reveal 的博客里就有这么一篇文章 Constraints & Transformations 讲述了 iOS 8 里两者是怎么愉快相处的。我也扯远了。那个高票回答里提出一种解决方案,使用子视图,将要调整的视图内嵌在容器视图里,在容器视图内调整 anchorPoint 和旋转,一举两得。但我已经找到另外一种更简单的方法。

首先,先转换到 Auto Layout 的环境下,这时候不能像 Frame Based Layout 那样设定约束了。实际上大部分还是相同的,只不过在使用时会修改一些我以前不知道的地方罢了。所有视图依然居中,还记得上一节那个配图中的约束公式吗,那个常量值为0,以前我直接修改 frame,现在修改这个常量值就可以达到同样的目的;宽高比依然设定为4:3,宽度设定为400,在布局时修改常量值修改宽度,而高度则由 Auto Layout 引擎计算出来,不像之前直接设定长宽数值,其实之前也可以直接修改约束,但我不知道可以修改。除了还要设定内嵌的图像视图的约束,这就完了。


重新设定约束

通常我们这样调整 anchorPoint 让视图不发生漂移:

subView.frame = frame
subView.layer.anchorPoint = CGPointMake(0.5, 1)
subView.frame = frame

事实上,用 constraint 的方式来实现这个手法就可以解决这个问题了:修改 anchorPoint 后,视图的位置发生了移动,那么补偿这段移动就可以了。具体的计算方法可能要根据约束的条件来决定,这点不如 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 的问题不用解决(从 iOS 8 起两者就很愉快地相处了,如果你需要适配8之前的版本,抱歉,不在本文范围内),代码中的其他改动也只是模块化后的变动。

小总结

这次动画的最大难点在于调整 anchorPoint,搞清楚机制后这个问题就很简单了。 对于使用 frame 还是 Auto Layout,后者无疑是适应性布局的首选,虽然复杂了一些,坑也有不少,但值得入坑。

Auto Layout 不断在改进,所以一些老问题就消失了,transform 的问题就是。实际上 transform 跟 Auto Layout 没有交集,AutoLayout 只对约束有效,transform 并没有修改约束条件,两者互不干扰。而 transform 跟 frame 的关系也很有意思,transform 对视图的 bounds 和 center 两个属性并没有影响,只对 frame 有影响,自己可以在代码中验证一下。Auto Layout 和 frame,使用前者时最好不要直接修改 frame,虽然也能按照你的意愿工作,但指不定不注意就掉坑里了。

起初实现的时候没有考虑那么多,这类动画还会有重排序、删除和添加卡片的需求,后续有空会尝试把这几个功能补上,另外,有时间的话会考虑做成提供数据源后一键使用的样子。

参考资料链接:

  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

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多