# iOS开发之按钮动画

语言：zh-CN
原文：[iOS开发之按钮动画](<https://allluckly.cn/%E6%8A%95%E7%A8%BF/tougao59>)
中文：[中文 HTML](<https://allluckly.cn/%E6%8A%95%E7%A8%BF/tougao59>)
English: [English HTML](<https://allluckly.cn/en/journal/2016-09-20-animating-ios-buttons-with-core-animation/>)
发布于: 2016-09-20T00:00:00+08:00
内容更新于: 2016-09-20T00:00:00+08:00
署名：Sindri

承接 Core Animation 基础，用 CALayer、CABasicAnimation 与动画组实现按钮的渐变和形变，说明图层属性、时间设置以及多个动画配合时的组织方式。

原文署名Sindri，Bison 编辑收录。

[原文所列来源](<http://www.jianshu.com/p/2b73dd95b125>)

**阅读前** 这是 2015–2017 年间的技术记录，本次整理未重新运行验证示例。涉及的 API、依赖和工具版本请结合发表日期阅读。

在上一篇[认识CoreAnimation](<http://sindrilin.com/animate/2016/06/07/%E8%AE%A4%E8%AF%86CoreAnimation>)中笔者介绍了系统的动画库`CoreAnimation`，使用动画库有很多好处，这里就不再进行重复叙述。那么本篇将承接上一篇的内容，使用提到的基础的动画相关类来实现动画效果

编辑：[Bison](<https://allluckly.cn/>)  
 来源：[Sindri的小巢](<http://www.jianshu.com/p/2b73dd95b125>)

效果图放上：

![1](<https://allluckly.cn/images/blog/tuogao/tougao59/1.gif>)

大体上可以看到demo主要是渐变以及形变两种动画，在更早之前的文章，我们就使用`UIView`的动画接口完成过相同的动画，而这次将换成`CoreAnimation`来完成这些工作

## 关于图层

在iOS中，每一个`UIView`都拥有一个与之绑定的`CALayer`图层对象，其负责视图内容的绘制与显示。跟前者一样，`CALayer`也拥有树状的子图层结构，以及相似的接口方法。`CALayer`是图层的基类，主要提供了视图显示范围、图层结构接口等属性，我们通过使用它的子类。下面是一段在控制器的界面中心添加一个圆形的紫色图层：

```
override func viewDidLoad() {
    super.viewDidLoad()
    
    let layer = CAShapeLayer()
    layer.fillColor = UIColor.purpleColor().CGColor
    layer.path = UIBezierPath(arcCenter: CGPoint(x: UIScreen.mainScreen().bounds.width / 2, y: UIScreen.mainScreen().bounds.height / 2), radius: 100, startAngle: 0, endAngle: 2.0*CGFloat(M_PI), clockwise: false).CGPath
    self.view.layer.addSublayer(layer)
}
```

同样的，每一个`CALayer`存在一个`sublayers`的数组属性，我们也可以遍历这个数组来完成移除子视图之类的操作：

```
for sublayer in self.view.layer.sublayers! {
    print("\(sublayer)")
    sublayer.removeFromSuperlayer()
}
```

由于核心动画框架的动画都是基于`CALayer`的图层进行添加实现的，所以图层的添加移除方法是最常用的方法。当然，还有一个`addAnimation(anim:forKey:)`接口用来给图层添加动画

## 基础动画

基础动画`CABasicAnimation`是最常用来实现动画效果的动画类，其继承自`CAAnimation`动画基类，为图层动画效果实现了一个`keyPath`属性，我们通过设置这个属性来为对应的`keyPath`属性值执行动画效果。动画类提供了`fromValue`和`toValue`两个属性用来设置动画的起始和结束的值，比如下面一段代码让添加到视图上的紫色图层变得透明：

```
@IBAction func actionToAnimatedLayer(sender: AnyObject) {
    let animation = CABasicAnimation(keyPath: "opacity")
    animation.fromValue = NSNumber(double: 1)
    animation.toValue = NSNumber(double: 0)
    animation.duration = 1
    layer.addAnimation(animation, forKey: nil)
}
```

![1](<https://allluckly.cn/images/blog/tuogao/tougao59/2.gif>)

上面的代码用动画表现了在1秒内让图层的`opacity`属性从`1`到`0`的过程。但上面不难看出在动画结束之后，紫色的图层没有保持`opacity`等于`0`的状态，而是回到了动画最开始的状态。这是为什么呢？

在上一篇中笔者提到过在每一个`CALayer`中存在着`模型`、`呈现`、`渲染`三种图层树，正是这些图层树共同作用来完成隐式动画。那么使用核心动画的时候，实际上`CABasicAnimation`会根据动画时长计算出每一帧的动画属性的值，然后实时提交给`呈现树`来展示对应时间点的视图效果，在动画结束时`CAAnimation`对象会自动从图层上移除。而由于在整个动画过程`模型树`的值没有改变，所以在动画结束的时候`呈现树`会再次从`模型树`获取图层的属性重新绘制。对此，存在这几种解决方案：

- 在实现动画的时候同时修改`opacity`，保证模型树的数据同步

  ```
  @IBAction func actionToAnimatedLayer(sender: AnyObject) {
      let animation = CABasicAnimation(keyPath: "opacity")
      animation.fromValue = NSNumber(double: 1)
      animation.toValue = NSNumber(double: 0)
      animation.fillMode = kCAFillModeForwards
      animation.removedOnCompletion = false
      animation.duration = 1
    
      layer.addAnimation(animation, forKey: nil)
  }
  ```
- 取消`CAAnimation`的自动移除，并且设置在动画结束后保持动画的结束状态

  ```
    @IBAction func actionToAnimatedLayer(sender: AnyObject) {
        let animation = CABasicAnimation(keyPath: "opacity")
        animation.fromValue = NSNumber(double: 1)
        animation.toValue = NSNumber(double: 0)
        animation.fillMode = kCAFillModeForwards
        animation.removedOnCompletion = false
        animation.duration = 1
        layer.addAnimation(animation, forKey: nil)
    }
  ```
- 实现动画代理方法。综合上面两种方法的操作

  ```
    @IBAction func actionToAnimatedLayer(sender: AnyObject) {
        let animation = CABasicAnimation(keyPath: "opacity")
        animation.fromValue = NSNumber(double: 1)
        animation.toValue = NSNumber(double: 0)
        animation.fillMode = kCAFillModeForwards
        animation.removedOnCompletion = false
        animation.duration = 1
			
        animation.setValue(layer, forKey: "animatedLayer")
        animation.delegate = self
        layer.addAnimation(animation, forKey: nil)
    }

    override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
        if anim is CABasicAnimation {
            let animation = anim as! CABasicAnimation
            if let layer = animation.valueForKey("animatedLayer") as? CALayer {
                layer.setValue(animation.toValue, forKey: animation.keyPath!)
                layer.removeAllAnimations()
            }
        }
    }
  ```

相比较前两种方法，实现代理然后设置属性的做法有些繁杂且无用的感觉。但在某些应用场景下，我们需要在动画结束时移除图层或其他操作，通过实现代理是最好的做法。其他常用的`keyPath`动画值可以在[这里](<http://www.cnblogs.com/bucengyongyou/archive/2012/12/20/2826590.html>)查看

## 动画组

接着上面的动画效果，我想要在渐变的基础上增加一个形变动画，那么我需要创建两个`CABasicAnimation`对象来完成这一工作：

```
@IBAction func actionToAnimatedLayer(sender: AnyObject) {
    let opacity = CABasicAnimation(keyPath: "opacity")
    opacity.fromValue = NSNumber(double: 1)
    opacity.toValue = NSNumber(double: 0)
    opacity.duration = 1
    
    layer.addAnimation(opacity, forKey: "opacity")
    
    let scale = CABasicAnimation(keyPath: "transform")
    scale.fromValue = NSValue(CATransform3D: CATransform3DIdentity)
    scale.toValue = NSValue(CATransform3D: CATransform3DMakeScale(2, 2, 2))
    scale.duration = 1
    
    layer.addAnimation(scale, forKey: "scale")
}
```

除了上面这段代码之外，在`CoreAnimation`框架中提供了一个`CAAnimationGroup`类来将多个动画对象整合成一个对象添加到图层上。从使用实现的角度而言，并不会跟上面的代码有任何出入，却可以让代码的逻辑更加清晰：

```
@IBAction func actionToAnimatedLayer(sender: AnyObject) {
    // create animations

    let group = CAAnimationGroup()
    group.animations = [opacity, scale]
    group.duration = 1
    layer.addAnimation(group, forKey: "group")
}
```

## 按钮动画

首先是动画中的形变和透明渐变分别对应`transform`以及`opacity`两个`keyPath`，其次，动画图层不是按钮本身的图层，因此还需要添加额外的一个图层。另外，动画存在外扩和内扩的动画效果，因此我们还需要定义一个枚举来区分：

```
enum LXDAnimationType {
    case Inner
    case Outer
}
```

在swift的`extension`中不支持添加储值属性，因此我们需要使用到`runtime`的动态绑定来完成对按钮包括动画类型、动画颜色两个属性的扩充：

```
private var kAnimationTypeKey: UInt = 0
private var kAnimationColorKey: UInt = 1
extension UIButton {

    enum LXDAnimationType {
        case Inner
        case Outer
    }

    //MARK: - Expand property
    var animationType: LXDAnimationType? {
        get {
            if let type = (objc_getAssociatedObject(self, &kAnimationTypeKey) as? String) {
                return LXDAnimationType(rawValue: type)
            }
            return nil
        }
        set {
            guard newValue != nil else { return }
            self.clipsToBounds = (newValue == .Inner)
            objc_setAssociatedObject(self, &kAnimationTypeKey, newValue!.rawValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }

    var animationColor: UIColor {
        get {
            if let color = objc_getAssociatedObject(self, &kAnimationColorKey) {
                return color as! UIColor
            }
            return UIColor.whiteColor()
        }
        set {
            objc_setAssociatedObject(self, &kAnimationColorKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
}
```

接下来是如何保证我们在点击按钮的时候可以执行我们的动画。这里我们通过重写按钮的`sendAction(action:to:forEvent:)`方法来执行动画，这个方法在每次按钮发送一个事件时会被调用。同理，当用户点击按钮时也会调用这个方法：

```
//MARK: - Override
public override func sendAction(action: Selector, to target: AnyObject?, forEvent event: UIEvent?) {
    super.sendAction(action, to: target, forEvent: event)
    
    if let type = animationType {
        var rect: CGRect?
        var radius = self.layer.cornerRadius
        
        var pos = touchPoint(event)
        let smallerSize = min(self.frame.width, self.frame.height)
        let longgerSize = max(self.frame.width, self.frame.height)
        var scale = longgerSize / smallerSize + 0.5
        
        switch type {
        case .Inner:
            radius = smallerSize / 2
            rect = CGRect(x: 0, y: 0, width: radius*2, height: radius*2)
            break
            
        case .Outer:
            scale = 2.5
            pos = CGPoint(x: self.bounds.width/2, y: self.bounds.height/2)
            rect = CGRect(x: pos.x - self.bounds.width, y: pos.y - self.bounds.height, width: self.bounds.width, height: self.bounds.height)
            break
        }
        
        let layer = animateLayer(rect!, radius: radius, position: pos)
        let group = animateGroup(scale)
        self.layer.addSublayer(layer)
        group.setValue(layer, forKey: "animatedLayer")
        layer.addAnimation(group, forKey: "buttonAnimation")
    }
}

public override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
    if let layer = anim.valueForKey("animatedLayer") as? CALayer {
        layer .removeFromSuperlayer()
    }
}


//MARK: - Private
private func touchPoint(event: UIEvent?) -> CGPoint {
    if let touch = event?.allTouches()?.first {
        return touch.locationInView(self)
    } else {
        return CGPoint(x: self.frame.width/2, y: self.frame.height/2)
    }
}

private func animateLayer(rect: CGRect, radius: CGFloat, position: CGPoint) -> CALayer {
    let layer = CAShapeLayer()
    layer.lineWidth = 1
    layer.position = position
    layer.path = UIBezierPath(roundedRect: rect, cornerRadius: radius).CGPath
    
    switch animationType! {
    case .Inner:
        layer.fillColor = animationColor.CGColor
        layer.bounds = CGRect(x: 0, y: 0, width: radius*2, height: radius*2)
        break
        
    case .Outer:
        layer.strokeColor = animationColor.CGColor
        layer.fillColor = UIColor.clearColor().CGColor
        break
    }
    return layer
}

private func animateGroup(scale: CGFloat) -> CAAnimationGroup {
    let opacityAnim = CABasicAnimation(keyPath: "opacity")
    opacityAnim.fromValue = NSNumber(double: 1)
    opacityAnim.toValue = NSNumber(double: 0)
    
    let scaleAnim = CABasicAnimation(keyPath: "transform")
    scaleAnim.fromValue = NSValue(CATransform3D: CATransform3DIdentity)
    scaleAnim.toValue = NSValue(CATransform3D: CATransform3DMakeScale(scale, scale, scale))
    
    let group = CAAnimationGroup()
    group.animations = [opacityAnim, scaleAnim]
    group.duration = 0.5
    group.delegate = self
    group.fillMode = kCAFillModeBoth
    group.removedOnCompletion = false
    return group
}
```

扩展之后的按钮只要设置`animationType`这个属性之后就会实现在点击时的动画效果

```
animateButton.animationType = .Outer
```

## 尾话

相比起国外的应用，国内的动画效果要显得内敛得多，甚至很多的app是没考虑过动画制作的。但是在移动端开发已然是一片血海的今天，漂亮的动画效果仍然会为你的应用带来留存，前提是你的应用要靠谱——单纯的动效留不住人。因此，掌握动画是至关重要的一项基本技能。[本文demo](<https://github.com/JustKeepRunning/Animations>)

---

好文推荐：[分分钟解决iOS开发中App启动动画广告的功能](<https://allluckly.cn/lblaunchimagead/LBLaunchImageAd>)

