【Swift4】UIBezierPathをアニメーションさせる

前回、矢印をUIView上に描画する例を示した。
tsukader.hatenablog.com

上の記事では、UIViewのdraw(_ rect: CGRect)関数でUIBezierPathの矢印を描画し、そのViewをMainのViewにaddSubViewするという手法だった。

今回はその矢印をアニメーションによって動かしてみようと思う。
方法としては、CAShapeLayer上にUIBezierPathで矢印を描画し、CoreAnimationのCABasicAnimationによってその矢印を動かす。
CAShapeLayerはUIViewのサブクラスとして定義したRepeatArrowクラスのlayerにaddSublayerする。

まずは前回同様、UIBezierPathをextensionによって拡張し、矢印を描画する関数「arrow」を定義する。
⌘Nで新しいSwiftファイルを生成し、以下のコードを記述する。このファイル名は任意。

extension UIBezierPath {
    static func arrow(from start: CGPoint, to end: CGPoint, tailWidth: CGFloat, headWidth: CGFloat, headLength: CGFloat) -> UIBezierPath {
        let length = hypot(end.x - start.x, end.y - start.y)
        let tailLength = length - headLength
        
        func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint { return CGPoint(x: x, y: y) }
        let points: [CGPoint] = [
            p(0, tailWidth / 2),
            p(tailLength, tailWidth / 2),
            p(tailLength, headWidth / 2),
            p(length, 0),
            p(tailLength, -headWidth / 2),
            p(tailLength, -tailWidth / 2),
            p(0, -tailWidth / 2)
        ]
        
        let cosine = (end.x - start.x) / length
        let sine = (end.y - start.y) / length
        let transform = CGAffineTransform(a: cosine, b: sine, c: -sine, d: cosine, tx: start.x, ty: start.y)
        
        let path = CGMutablePath()
        path.addLines(between: points, transform: transform)
        path.closeSubpath()
        
        return self.init(cgPath: path)
    }
}

これでいつでも矢印を描画できる。
次に、矢印を描画するためのUIViewと、その中のLayerを定義する。クラス名はRepeatArrowとする。
同じく⌘Nで新しいSwiftファイルを作成し、以下のコードを記述する。ファイル名はRepeatArrow.swiftが良いだろう。

class RepeatArrow: UIView {
    var arrow: CAShapeLayer?
    var fromPoint: CGPoint!
    var startArrowToPoint: CGPoint!
    var toPoint: CGPoint!
    var startArrow: UIBezierPath!
    var endArrow: UIBezierPath!
    
    init(frame: CGRect, from: CGPoint, to: CGPoint) {
        super.init(frame: frame)
        fromPoint = from
        toPoint = to
        startArrowToPoint = CGPoint(x: from.x + (to.x-from.x)*0.01, y: from.y + (to.y-from.y)*0.01)
        self.isOpaque = false
        self.isUserInteractionEnabled = false
        self.createArrow()
        self.animateArrow()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func animateArrow() {
        let bounce = CABasicAnimation(keyPath: "path")
        bounce.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseIn)

        //startArrow & endArrow
        startArrow = UIBezierPath.arrow(from: fromPoint, to: startArrowToPoint, tailWidth: 0.5, headWidth: 8, headLength: 8)
        endArrow = UIBezierPath.arrow(from: fromPoint, to: toPoint, tailWidth: 0.5, headWidth: 8, headLength: 8)
        
        //bounce
        bounce.fromValue = self.startArrow.cgPath
        bounce.toValue = self.endArrow.cgPath
        bounce.duration = 3.0
        bounce.isRemovedOnCompletion = false
        bounce.fillMode = kCAFillModeForwards
        bounce.repeatCount = .infinity
        self.arrow?.add(bounce, forKey: "return")
    }
    
    func createArrow(){
        arrow = CAShapeLayer(layer: self.layer)
        arrow?.lineWidth = 0.9
        arrow?.path = UIBezierPath.arrow(from: fromPoint, to: startArrowToPoint, tailWidth: 0.5, headWidth: 8, headLength: 8).cgPath
        arrow?.strokeColor = UIColor.black.cgColor
        arrow?.fillColor = UIColor.black.cgColor
        self.layer.addSublayer(arrow!)
    }
}

手順としては、createArrow関数によって初期状態の矢印を生成し、それをanimateArrow関数によって初期状態から最終状態までアニメーションさせている。

あとは矢印を描画したいView上で以下のようにして矢印を生成する。

let arrow1 = RepeatArrow(frame: self.view.frame, from: CGPoint(x:100, y:100), to: CGPoint(x:200, y:200))
self.view.addSubview(arrow1)

これで、好きな位置に矢印を描画することができますね。

さいごに

今回は始点から終点まで伸びる矢印を作りたかったため、初期状態では.arrow(from: 始点, to: 始点)というような矢印であって欲しかったが、それでは始点と終点が同じ位置になるせいでエラーが返されてしまったので、startArrowToPointというCGPointを用意し、fromPointからtoPoint方向に全体の0.01倍だけ進んだ点を取り、初期状態の終点とした。

Error: this application, or a library it uses, has passed an invalid numeric value (NaN, or not-a-number) to CoreGraphics API and this value is being ignored. Please fix this problem.

といったエラーが出たら、そのような指定をしていないか疑ってみると良い。

また、Core Animationについては以下の資料が参考になるだろう。
アニメーション終了時に減速させる設定や、アニメーションの繰り返しなどについて詳しく解説されている。
https://developer.apple.com/jp/documentation/CoreAnimation_guide.pdf
https://developer.apple.com/jp/documentation/Animation_Types_Timing.pdf