カスタムCAShapeLayerクラスのAnimatableなプロパティでアニメーションを動かす

# 概要

CAShapeLayerで描画されたViewをアニメーションさせたいケースがあると思います。

`strokeStart`や`strokeEnd`などのCAShapeLayerで定義しているプロパティを指定することで、プロパティ変化に応じてCABasicAnimationによるアニメーションを実行することができます。

しかし、描画する内容によっては、独自のプロパティを指定してアニメーションを実行させたい時があります。

そこで本記事では、円グラフを描画するCAShapeLayerクラスを例に、Animatableなプロパティを定義する方法について説明します。

# 完成イメージ

![画面収録-2023-11-24-18.56.03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368292/9c8845d0-a669-fcf5-5781-ea73cf998dbe.gif)


# 手順

1. カスタムCAShapeLayerクラスを作成
1. カスタムCAShapeLayerを持つViewを作成
1. Viewを利用

## カスタムCAShapeLayerクラスを作成

```swift
final class ArcLayer: CAShapeLayer {
    var tintColor: UIColor = .black

    @objc dynamic var startAngle: CGFloat = 0.0
    @objc dynamic var endAngle: CGFloat = 0.0

    override init() {
        super.init()
    }

    override init(layer: Any) {
        if let arcLayer = layer as? ArcLayer {
            self.tintColor = arcLayer.tintColor
            self.startAngle = arcLayer.startAngle
            self.endAngle = arcLayer.endAngle
        }
        super.init(layer: layer)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func draw(in ctx: CGContext) {
        ctx.clear(bounds)

        let center = CGPoint(x: bounds.width / 2.0, y: bounds.height / 2.0)
        let radius: CGFloat = 100
        let offset: CGFloat = -90
        let startAngleRadians: CGFloat = (startAngle + offset).radian
        let endAngleRadians: CGFloat = (endAngle + offset).radian

        ctx.beginPath()

        let path = CGMutablePath()
        path.move(to: center)
        path.addArc(
            center: center,
            radius: radius,
            startAngle: startAngleRadians,
            endAngle: endAngleRadians,
            clockwise: false
        )
        ctx.addPath(path)

        ctx.setFillColor(self.tintColor.cgColor)
        ctx.setStrokeColor(UIColor.clear.cgColor)

        ctx.drawPath(using: .fill)
    }

    override class func needsDisplay(forKey key: String) -> Bool {
        if key == #keyPath(ArcLayer.endAngle) || key == #keyPath(ArcLayer.startAngle) {
            return true
        }

        return super.needsDisplay(forKey: key)
    }
}
```

## カスタムCAShapeLayerを持つViewを作成

```swift
final class ArcView: UIView {
    override class var layerClass: AnyClass {
        ArcLayer.self
    }

    private var arcLayer: ArcLayer {
        layer as? ArcLayer ?? .init()
    }

    var endAngle: CGFloat {
        get {
            arcLayer.endAngle
        }

        set {
            arcLayer.endAngle = newValue
            arcLayer.setNeedsDisplay()
        }
    }

    override func tintColorDidChange() {
        super.tintColorDidChange()
        arcLayer.tintColor = tintColor
        arcLayer.setNeedsDisplay()
    }

    func increment(newEndAngle: CGFloat) {
        guard arcLayer.endAngle < 360 else { return }
        arcLayer.removeAllAnimations()

        let anim = CABasicAnimation(keyPath: #keyPath(ArcLayer.endAngle))
        anim.fromValue = endAngle
        anim.toValue = endAngle + newEndAngle
        anim.duration = 0.5

        endAngle = endAngle + newEndAngle
        arcLayer.add(anim, forKey: "increment")
    }

    func decrement(newEndAngle: CGFloat) {
        guard arcLayer.endAngle > 0.0 else { return }
        arcLayer.removeAllAnimations()

        let anim = CABasicAnimation(keyPath: #keyPath(ArcLayer.endAngle))
        anim.fromValue = endAngle
        anim.toValue = endAngle - newEndAngle
        anim.duration = 0.5

        endAngle = max(endAngle - newEndAngle, 0)
        arcLayer.add(anim, forKey: "decrement")
    }
}
```

## Viewの利用


```swift
import UIKit

final class CustomCALayerProperty: UIViewController {
    private var arcView = ArcView()

    override func viewDidLoad() {
        super.viewDidLoad()
        // 省略
    }

    private func setupPlusMinusButtons() {
        // ボタンの設定（省略）
        let plusButton = UIButton()
        let minusButton = UIButton()

        plusButton.addAction(.init(handler: { [weak self] _ in
            self?.arcView.increment(newEndAngle: 90)
        }), for: .touchUpInside)

        minusButton.addAction(.init(handler: { [weak self] _ in
            self?.arcView.decrement(newEndAngle: 90)
        }), for: .touchUpInside)

        // 省略
    }
}
```

以上です。