Bootstrap

八.核心动画 - 隐式动画

引言

在前面的博客中我们已经讨论了Core Animation除了动画之外可以做到的事情,这篇博客算是它们的一个分界线。从这开始我们将介绍Core Animation的动画特性。

隐式动画

本篇博客我们首先来讨论一下由框架自动完成的隐式动画。这或许帮助大家解释我们在使用动画时遇到的一些奇怪的问题(灵异事件)。

隐式动画中的事务

在我们的通常的观念中会认为动画是需要手动添加和开启的,否则是不会存在的。但事实上正好相反Core Animation框架定义屏幕上任何东西都需要做动画,而不需要手动开启,相反你需要明确的手动关闭它,否则它将一直存在。

当你改变CALayer的一个属性时,它实际上并不会立刻生效,而是从原来的值平滑地过渡到我们新设置的值。这个行为是默认的,并不需要我们做任何设置。

来列举一个例子,创建一个方形色块,点击时改变颜色,代码如下:

    let colorLayer = CALayer()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        colorLayer.frame = CGRect(x: 50, y: 50, width: 100, height: 100)
        colorLayer.backgroundColor = UIColor.red.cgColor
        view.layer.addSublayer(colorLayer)
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let colors = [UIColor.orange.cgColor, UIColor.green.cgColor, UIColor.blue.cgColor]
        colorLayer.backgroundColor = colors.randomElement()
    }

点击屏幕我们会发现,颜色在发生变化时,并没有非常果断的改变,而是有一个明显的过渡效果,这就是所谓隐式动画

之所有叫做隐式动画,是因为我们并没有指定任何动画的属性和类型,我们只是修改了图层的一个属性。然后由Core Animation来决定如何做动画,什么时候做动画。

而它做动画的时长取决于当前的事务设置,而动画的类型取决于图层的行为。

事务实际上是Core Animation用来包含一系列属性动画集合的机制,任何用指定事务去改变可以做动画的图层属性都不会立刻发生变化,而是当事务提交时开始用动画进行过渡。

事务通过CATransaction来做管理。CATransaction使用类方法+begin和+commit用来入栈和出栈,任何可以做动画的图层属性都会被添加到栈顶的事务,我们可以通过+setAnimationDuration:方法来设置当前事务的动画时间(默认为0.25秒),+setAnimationTimingFunction方法来设置当前事务的动画时间函数(默认线性)。

Core Animation会在每个run loop周期中自动开始一次新的事物,其实我们不调用+begin,我们设置的图层属性改变也会被集中起来,然后做一次0.25秒动画。这就是为什么我们在修改图层颜色时,它没有直接变化。

下面我们就来修改一下动画的时间。虽然我们可以直接修改当前事务的动画时间,但是它可能会有些副作用,比如修改了所有图层的默认动画时间,所以我们最好还是起一个新的事物,将动画的调整在新的事物中进行。

代码如下:

    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let colors = [UIColor.orange.cgColor, UIColor.green.cgColor, UIColor.blue.cgColor]
        CATransaction.begin()
        CATransaction.setAnimationDuration(2.0)
        colorLayer.backgroundColor = colors.randomElement()
        CATransaction.commit()
    }

你会发现,颜色在改变时变的非常缓慢。

这和我们使用UIView的 +animate(withDuration duration: TimeInterval, animations: @escaping () -> Void)方法做动画是一样的。

CATransaction的+begin和+commit方法都会再该方法的内部自动调用。这样block中的所有属性的改变都会被事务所包含。

其实在iOS 4 之前,UIView有两个方法+beginAnimations:context:和+commitAnimations是和CATransaction的两个方法完全对应的,iOS 4之后提供了这个基于block的方法,避免开发者由于+begin和commit不匹配造成的风险。

隐式动画完成回调

我们使用UIView做动画在动画结束的时候会有一个completion回调。CATranscation同样也提供了相同的功能。

我们就来使用这个功能,在颜色修改完成之后做另外一个动画,代码如下:

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let colors = [UIColor.orange.cgColor, UIColor.green.cgColor, UIColor.blue.cgColor]
        CATransaction.begin()
        CATransaction.setAnimationDuration(2.0)
        CATransaction.setCompletionBlock {
            var transform = self.colorLayer.affineTransform()
            transform = transform.rotated(by: CGFloat.pi / 4)
            self.colorLayer.setAffineTransform(transform)
        }
        colorLayer.backgroundColor = colors.randomElement()
        CATransaction.commit()
    }

我们发现在修改完成颜色之后,色块发生了旋转的动画,但速度会比颜色改变的快许多,因为在执行这个改变时,事务已经提交出栈了,所以事实上它使用的事物默认的时间0.25秒。

隐式动画的图层行为

下面我们试着对UIView的关联图层做一个动画,而不是一个单独的图层,看看会有什么现象。

代码如下:

    let colorView = UIView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        colorView.frame = CGRect(x: 50, y: 50, width: 100, height: 100)
        colorView.layer.backgroundColor = UIColor.red.cgColor
        self.view.addSubview(colorView)
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        let colors = [UIColor.orange.cgColor, UIColor.green.cgColor, UIColor.blue.cgColor]
        CATransaction.begin()
        CATransaction.setAnimationDuration(2.0)
        colorView.layer.backgroundColor = colors.randomElement()
        CATransaction.commit()
    }

运行程序之后我们发现,使用事务设置的动画时长并没有生效,甚至连默认的0.25秒也没有出现。似乎UIView关联图层的隐式动画被禁用了。

仔细思考一下UIView的属性确实不应该有默认的动画,因为大多数变化都不需要动画。但是UIKit是建立在Core Animation之上的,那么这个隐式动画是怎么被UIKit禁用掉的呢?

为了更好的说明这一点,我们需要知道隐式动画是如何实现的,实际上是如下这几步:

  1. 图层首先检测是否有委托,并且委托是否实现了CALayerDelegate协议指定的-actionForLayer:forKey方法,如果有,则直接调用并返回结果。
  2. 如果没有委托或者没有实现actionForLayer:forKey方法,图层接着检查包含属性名称对应行为映射的actions字典。
  3. 如果actions字典也没有包含对应的属性,那么图层接着在它的style字典接着搜索属性名。
  4. 最后,如果在style里面也找不到对应的行为,那么图层将会直接调用定义了每个属性的标准行为的-defaultActionForkey:方法。

所以一轮完整的搜索结束之后,-actionForKey:要么返回空,那么就没有动画。妖魔是CAAction协议对应的对象,最后CALayer拿这个结果去对先前的和当前的值做动画。

这样我们就能知道UIKit实如何禁用了隐式动画,实际上UIView对它所关联的图层都扮演了一个委托的角色,并且提供了-actionForLayer:forKey方法的实现。当在一个动画块中,+animate(withDuration duration: TimeInterval, animations: @escaping () -> Void)它就返回一个非空的值,而不在动画块中,它就返回空。

我们来使用例子验证一下它,代码如下:

    let colorView = UIView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        colorView.frame = CGRect(x: 50, y: 50, width: 100, height: 100)
        colorView.layer.backgroundColor = UIColor.red.cgColor
        self.view.addSubview(colorView)
        print("隐式动画 - \(colorView.action(for: colorView.layer, forKey: "backgroundColor") as Any)")
        
        
        
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        UIView.animate(withDuration: 1.0) {
            print("隐式动画 - \(self.colorView.action(for:self.colorView.layer, forKey: "backgroundColor") as Any)")
            let colors = [UIColor.orange.cgColor, UIColor.green.cgColor, UIColor.blue.cgColor]
            self.colorView.layer.backgroundColor = colors.randomElement()
        }
    }

结果如下:

隐式动画 - Optional(<null>)

隐式动画 - Optional(<CABasicAnimation:0x600000240260; delegate = <UIViewAnimationState: 0x103e14ad0>; fillMode = both; timingFunction = easeInEaseOut; duration = 1; highFrameRateReason = 1048609; preferredFrameRateRangePreferred = 120; preferredFrameRateRangeMaximum = 120; preferredFrameRateRangeMinimum = 30; fromValue = <CGColor 0x60000261c5a0> [<CGColorSpace 0x60000261c300> (kCGColorSpaceICCBased; kCGColorSpaceModelRGB; sRGB IEC61966-2.1; extended range)] ( 1 0 0 1 ); keyPath = backgroundColor>)

所以视图的属性改变如果在动画块之外,UIView通过直接返回nil来禁用动画,如果是在动画块内部,则会根据动画类型返回响应的属性。

不过这也不是唯一的方法,CATransaction还提供了一个+setDisableActions:可以直接禁用隐式动画。

关于图层行为我们知道了以下几点:

  1. UIView关联的图层禁用了隐式动画。可以使用UIView的动画函数,或者继承UIView覆盖-actionForLayer:forKey:方法,或者直接使用显示动画,来为视图添加动画。
  2. 对于单独的图层,我们可以通过实现-actionForLayer:forKey委托方法,或者提供一个actions字典来控制饮食动画。

接下来我们就来应用它,给上面的colorLayer自定义一个动画行为,通过给它设置一个自定义的actions字典。这里我们使用了一个实现了CATransaction的实例,叫做推进过渡。

代码如下:

    
    let colorLayer = CALayer()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        colorLayer.backgroundColor = UIColor.red.cgColor
        colorLayer.frame = CGRect(x: 50, y: 50, width: 100, height: 100)
        
        // 添加动画
        let transition = CATransition()
        transition.type = .push
        transition.subtype = .fromRight
        colorLayer.actions = ["backgroundColor": transition]
        self.view.layer.addSublayer(colorLayer)
        
        
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        
                    let colors = [UIColor.orange.cgColor, UIColor.green.cgColor, UIColor.blue.cgColor]
                    self.colorLayer.backgroundColor = colors.randomElement()

    }

运行代码,我们会发现它的默认动画行为已经被修改了,效果如下:

隐式动画的模型与显示

隐式动画有一点魔幻,但是会让我们觉得这很不正常,为什么当我们改变一个属性的时候它不立刻生效,而是通过一段时间渐变来更新呢?

事实上,当我们修改一个图层的属性时,它的确是立刻更新的(如果你打印图层的颜色属性,会发现它已经改变了)。只是在屏幕上的显示没有马上发生改变,这是因为我们设置的属性并没有直接调整图层的外观,而是只定义了图层动画结束之后将要变化的外观。

当我们修改CALayer的属性时,实际上是在定义图层在当前事务结束之后图层如何显示的模型。Core Animation扮演了一个控制器的角色,它根据图层的行为和事务的设置来不断的更新视图在屏幕上的显示状态。

实际上,在界面本身这个场景下,CALayer的行为更像是存储了视图如何显示和动画的数据模型。在iOS中,通常屏幕每秒会绘制60次,如果动画的时长比60分之一要长,Core Animation就要在设置一次新值和新值生效之间对屏幕的图层进行重新组织。这就意味着CALayer除了真实值之外,还需要知道当前显示在屏幕上的属性值的记录。

而每个图层属性的显示值都被存储在一个叫做呈现图层的独立图层当中,我们可以通过-presentationLayer方法来访问。呈现图层实际上是模型图层的复制图层,它的属性值才是我们看见的值,也及时任何指定时间当前的外观。

我们在最初的博客中提到过图层树,同样还有一个呈现树,呈现树是通过图层树中所有的图层的呈现图层所构成的。

注意呈现图层仅仅当图层首次被提交的时候创建,所以在那之前调用-presentationLayer会返回nil。

图层还有一个-modelLayer方法,在呈现图层上调用该方法会返回它所依赖的图层,也就是原始图层。通常情况下会返回它本身,既呈现图层和模型图层是同一个图层。

通常来讲我们不需要直接访问呈现图层,开发过程中我们大多数是与模型图层来进行交互,然后让Core Animation来更新显示。

但是也会有使用呈现图层的场景,一个是同步动画,一个是处理动画中的用户交互。

  • 如果你在实现一个基于定时器的动画,这时候准确地知道在某一时刻图层的显示会很有帮助。
  • 如果你想让做动画的图层响应用户的点击,这时候对呈现图层调用-hitTest会更准确。

下面我们来列举一个案例,使用presentationLayer图层来判断当前图层位置,代码如下:

    let colorLayer = CALayer()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        colorLayer.backgroundColor = UIColor.red.cgColor
        colorLayer.frame = CGRect(x: 50, y: 50, width: 100, height: 100)
        self.view.layer.addSublayer(colorLayer)
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let point = touches.first?.location(in: self.view) else { return }
        if colorLayer.presentation()?.hitTest(point) != nil {
            let colors = [UIColor.orange.cgColor, UIColor.green.cgColor, UIColor.blue.cgColor]
            self.colorLayer.backgroundColor = colors.randomElement()
        } else {
            CATransaction.begin()
            CATransaction.setAnimationDuration(4)
            colorLayer.position = point
            CATransaction.commit()
        }
        
    }

我们发现在执行移动动画的时候,直接点击我们看见的呈现图层会触发颜色改变的动画。

总结

本篇博客我们讨论了一下隐式动画,这帮我们解释了为什么有些图层明明没有添加动画在改变属性时却莫名其妙的出现了动画。

也知道了UIKit是如何充分利用Core Animation的隐式动画机制来强化显示的。

最后我们介绍了图层的呈现图层和模型图层。

下一篇博客我们将来介绍Core Animation提供的显示动画。

;