【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

【Swift4】矢印を描画する

UIViewController上に矢印を描画したい。

幾つかの方法があるようだが、今回はUIBezierPathによる描画を試みた。

参考にしたのは以下のサイト:
qiita.com
i-app-tec.com


では本題。
構想としては、画面上にUIView()を生成し、その中に始点と終点を指定して矢印を描画していく。
動的に矢印を増やしたいため、1つのUIView()につき1つの矢印を描画する(静的な場合は1つのUIView()に複数の矢印を描画することも可能です)。

まず、UIViewのサブクラスとしてArrowViewクラスを作ります。
⌘Nで新しいファイルを作成 -> [Swift File] を選択。タイトルはArrowViewとします。

import UIKit

class ArrowView: UIView {
    var fromPoint: CGPoint!
    var toPoint: CGPoint!
    
    init(frame: CGRect, from: CGPoint, to: CGPoint) {
        super.init(frame: frame)
        fromPoint = from
        toPoint = to
        self.isOpaque = false
        self.isUserInteractionEnabled = false
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
        
    override func draw(_ rect: CGRect) {
        let arrow = UIBezierPath.arrow
                                ( from: fromPoint,
                                  to: toPoint,
                                  tailWidth: 0.5,
                                  headWidth: 8,
                                  headLength: 8 )
        UIColor.black.setStroke()
        UIColor.black.setFill()
        arrow.lineWidth = 0.9
        arrow.stroke()
    }
}

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)
    }
}

次に、表示したいView上で以下のコードでArrowViewを生成し、addSubViewします。
今回は ViewController: UIViewController 上に生成してみます。

class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        //ArrowViewを生成
        let arrow1 = ArrowView(frame: view.frame, from: CGPoint(x:100, y:100), to: CGPoint(x:10, y:10))
        self.view.addSubview(arrow1)
    }
}

これで矢印が描画できると思います。
各種プロパティは用途に応じて適宜変更してください。

isOpaque = false //背景色を黒にしない(デフォルトはtrue: 黒)
isUserInteractionEnabled = false // タップを無効化する
UIColor.black.setStroke() // 矢印の色を設定
UIColor.black.setFill() // 中抜きにしない
arrow.lineWidth = 0.9 // 線の太さを設定

【Swift4】UIViewControllerの表示画面を更新・リロードする

UIViewControllerの表示画面を更新・リロードしたい場面が出てきた。

例えばTableViewControllerであれば、TableView.reloadViewというメソッドで処理できるが、UIViewControllerにはそのようなメソッドは標準では用意されていない場合は、ViewDidLoad()を利用すれば良い。

表示内容を更新したいだけであれば

self.ViewDidLoad()


もしViewの生成からやり直す場合は

self.loadView()
self.ViewDidLoad()

としてやれば良い。

ブログ開設・方針

当ブログは大学院生の私がプログラムを書いて行く上ではまった点やその解決手段に関する備忘録として開設しました。記事にする言語は主にSwiftですが、他にもRuby, Python, c++なども扱います。

 

SwiftはiOSアプリ開発用に作られたオブジェクト指向言語です(最近はプロトコル指向言語であるとも言います)。

iOSアプリ開発にはXcodeを開発環境として用いますが、その利用法は人によって異なるようです。具体的にはStoryboardを用いてコードと紐付けして開発するパターンと、Storyboardは用いずにコードのみで開発するパターンです。

 

私自身、初めてXcodeを用いて開発したのは2015年頃で、その頃は技術書を片手にStoryboardベースの開発をしていたものですが、AutoLayoutにつまづいたり、紐付けがうまくいかなかったりで、Storyboardを用いることによって省略できる部分がブラックボックス的に思えたのでコードベースの開発に切り替えた過去があります。

コードベースの開発ではボタンやラベルの位置や大きさ、色まで全てをコードで指定する必要がありますが、「コードに書いてあることが全て」であるおかげで、訳のわからないエラーは格段に減りました。しかし、iPhoneのバージョンによって画面の大きさが異なることへの対応などは骨の折れるもので、苦労した経験があります。

 

その後もたまにiOSアプリ開発は行いましたが、最近になって、改めてStoryboard開発にチャレンジしてみようと決心し、勉強し直しました。数年前からはSwiftのバージョンも更新され、文法が大きく変わったこともありますが、Xcode自体も改良され、当初に比べると非常に使いやすいものになったと感じています(当時のスキルが足りなかっただけかもしれませんが)。

AutoLayoutも克服し、各クラスの対応関係なども俯瞰できるようになってきました。しかし、Storyboardを使う上で直面する問題も多々あり、それらを解決する度に新しい知識が増えてきました。このブログではそんなことをまとめて行こうと思います。

 

記事では、問題解決の際に学んだ知識を一般化して書くのではなく、実際に行った手順を具体的に書いて行くことにします。一般化した方が普遍的で応用の効く知識になりますが、その分抽象的で初心者にはそこからの発展がわかりにくかったりすることが多いです。一般化した書き方は公式リファレンスや逆引き辞典系のサイトに任せるとして、ここでは敢えて具体的な書き方を採用します。

 

一見当たり前なことも記事にするとは思いますが、あくまで備忘録ということでお許しください。