【Swift 3】CALayerを用いて図形を移動・拡大縮小してみた【CALayer】
どんも。Reoです。
CALayerを使って丸や四角などの図形を生成し、移動・拡大縮小するコードをSwiftで書いたので紹介していきます。
これまでUIViewを作成してどうやらこうやらしていたのですが、その代わりにCALayerを使ってみると、とにかく便利で使えるやつだということに気がつきました。メモリ的にもありがたい・・・
CALayerを用いて図形を描こう
まずはCALayerを使って図形を描いてみます。
線を引いたり、丸を描いたり、四角を描いたりは簡単にできます。
図形を描く場合は、CAShapeLayerというCALayerを用います。
①丸を描く
let ovalShapeLayer = CAShapeLayer()
ovalShapeLayer.strokeColor = UIColor.blue.cgColor  // 輪郭は青
ovalShapeLayer.fillColor = UIColor.clear.cgColor  // 塗りはクリア
ovalShapeLayer.lineWidth = 1.0
ovalShapeLayer.path = UIBezierPath(ovalIn: CGRect(x:30, y:30, width:50, height:50)).cgPath
self.view.layer.addSublayer(ovalShapeLayer)
      
②横線を描く
let horizonalLine = CAShapeLayer()
horizonalLine.strokeColor = UIColor.black.cgColor
horizonalLine.lineWidth = 3.0
let line = UIBezierPath()
line.move(to: CGPoint(x:0,y:100))   //始点
line.addLine(to:CGPoint(x:self.view.frame.width,y:100))   //終点
line.close()  //線を結ぶ
horizonalLine.path = line.cgPath
self.view.layer.addSublayer(horizonalLine)
      この線はaddLineで追加していけば好きな図形を自由に描くことができます。
上記のような線を引く場合は、始点・終点のみを結びますが、もう1つaddLineでポイントを作ると、3つの点を結んだ塗りの部分が発生します。(三角形になる)
③四角を描く
let rect = CAShapeLayer()
rect.strokeColor = UIColor.black.cgColor
rect.fillColor = UIColor.green.cgColor
rect.lineWidth = 2.0
rect.path = UIBezierPath(rect:CGRect(x:100,y:100,width:100,height:100)).cgPath
self.view.layer.addSublayer(rect)
      
これらを実行すると以下のようになっているはずです。
ボタンをタップすると図形が生成されるようにするため、ボタンを設置しておきます。
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let width = self.view.bounds.width
        let height = self.view.bounds.height
        //丸を生成するボタン
        let ovalBtn = UIButton()
        ovalBtn.frame = CGRect(x:0,y:0,width:100,height:50)
        ovalBtn.center = CGPoint(x:width / 3,y:height - 30)
        ovalBtn.addTarget(self, action: #selector(ViewController.ovalBtnTapped(sender:)), for: .touchUpInside)
        ovalBtn.setTitle("丸",for:.normal)
        ovalBtn.backgroundColor = UIColor.green
        self.view.addSubview(ovalBtn)
        
        //四角を生成するボタン
        let rectBtn = UIButton()
        rectBtn.frame = CGRect(x:0,y:0,width:100,height:50)
        rectBtn.center = CGPoint(x:width * 2 / 3,y:height - 30)
        rectBtn.addTarget(self, action: #selector(ViewController.rectBtnTapped(sender:)), for: .touchUpInside)
        rectBtn.setTitle("四角",for:.normal)
        rectBtn.backgroundColor = UIColor.red
        self.view.addSubview(rectBtn)
    }
    func ovalBtnTapped(sender:UIButton){
        //丸を描く
    }
    func rectBtnTapped(sender:UIButton){
        //四角を描く
    }
      
ボタンを押したら図形を描く処理はとりあえず置いておきます。
タッチしたCALayerを取得して、そのCALayerを移動させよう
作成された図形レイヤーを移動できるようにしていきます。
まずはタッチイベントのコードを書いていきます。
先に選択したCALayerをいれておく箱と、直前にタッチされた場所を入れて置く変数を用意しておきます。
//選択したレイヤーをいれておく
private var selectLayer:CALayer!
//最後にタッチされた座標をいれておく
private var touchLastPoint:CGPoint!
      
タッチをした時
    //タッチをした時
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        //すでに選択されているレイヤーがあるかもしれないのでnilにしておく
        selectLayer = nil
        //タッチを取得
        let touch:UITouch = touches.first!
        //タッチした場所にあるレイヤーを取得
        let layer:CALayer = hitLayer(touch: touch)
        //タッチされた座標を取得
        let touchPoint:CGPoint = touch.location(in: self.view)
        //最後にタッチされた場所に座標を入れて置く
        touchLastPoint = touchPoint
        //選択されたレイヤーをselectLayerにいれる
        self.selectLayerFunc(layer:layer)
    }
      タッチをした場所にあるレイヤーを取得するhitLayer(touch:)を作成します。
    func hitLayer(touch:UITouch) -> CALayer{
        var touchPoint:CGPoint = touch.location(in:self.view)
        touchPoint = self.view.layer.convert(touchPoint, to: self.view.layer.superlayer)
        return self.view.layer.hitTest(touchPoint)!
    }
      座標を変換してレイヤーを返却しています。
returnのhitTest部分で座標上にあるレイヤーを取得しています。
選択されたレイヤーをselectLayerに入れている部分は
    func selectLayerFunc(layer:CALayer?) {
        if((layer == self.view.layer) || (layer == nil)){
            selectLayer = nil
            return
        }
        selectLayer = layer
    }
      タッチした座標に、view上でのせているレイヤーがない場合はselectLayerにnilをいれ、CALayerがあった場合にはそのレイヤーをそのレイヤーを格納しています。
タッチが動いた時
    //タッチが動いた時
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch:UITouch = touches.first!
        let touchPoint:CGPoint = touch.location(in:self.view)
        //直前の座標との差を取得
        let touchOffsetPoint:CGPoint = CGPoint(x:touchPoint.x - touchLastPoint.x,
                                               y:touchPoint.y - touchLastPoint.y)
        touchLastPoint = touchPoint
     
        if (selectLayer != nil){
            //hitしたレイヤーがあった場合
            let px:CGFloat = selectLayer.position.x
            let py:CGFloat = selectLayer.position.y
            //レイヤーを移動させる
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            selectLayer.position = CGPoint(x:px + touchOffsetPoint.x,y:py + touchOffsetPoint.y)
            selectLayer.borderWidth = 3.0
            selectLayer.borderColor = UIColor.green.cgColor
            CATransaction.commit()
        }
    }
      
タッチが終わった時・キャンセルされた時
    //タッチを終えた時
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        if(selectLayer != nil){
            selectLayer.borderWidth = 0
        }
    }
    //タッチがキャンセルされた時
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        if(selectLayer != nil){
            selectLayer.borderWidth = 0
        }
    }
      
タッチが終わった時とキャンセルされた時は選択時に表示されていた枠線を消す処理をしています。
ここまででとりあえずCALayerを動かすことができるようになります。
しかしどうやらhitTestで取得できるのはCALayerのみで、CAShapeLayerは取得できない(なんか処理を追加しないとだめっぽい)ので、とりあえずCALayerの上にさらにCAShapeLayerをのせて表示させるようにしました。
CALayerのサブクラスを作ろう
なんか適当にサブクラス作って扱いやすくしておきます。
File > New > Fileより
Source > Cocoa Touch Class
Subclass of をCALayerにして、適当に名前をつけて作成
この中に先ほどの図形を描く処理を関数で作っておきます。
import UIKit
class MyShapeLayer: CALayer {
    func drawRect(lineWidth:CGFloat){
        let rect = CAShapeLayer()
        rect.strokeColor = UIColor.black.cgColor
        rect.fillColor = UIColor.clear.cgColor
        rect.lineWidth = lineWidth
        rect.path = UIBezierPath(rect:CGRect(x:0,y:0,width:self.frame.width,height:self.frame.height)).cgPath
        self.addSublayer(rect)
    }
    
    func drawOval(lineWidth:CGFloat){
        let ovalShapeLayer = CAShapeLayer()
        ovalShapeLayer.strokeColor = UIColor.blue.cgColor
        ovalShapeLayer.fillColor = UIColor.clear.cgColor
        ovalShapeLayer.lineWidth = lineWidth
        ovalShapeLayer.path = UIBezierPath(ovalIn: CGRect(x:0, y:0, width:self.frame.width, height: self.frame.height)).cgPath
        self.addSublayer(ovalShapeLayer)
    }
}
      
CALayerの上に、そのCALayerのサイズと同じサイズのCAShapeLayerをのせています。
ボタンをタップされたときの処理を追加しよう
先ほど作成したボタンのアクション内に、丸と四角がそれぞれ書かれたレイヤーをviewのレイヤーに追加する処理を書きます。
  //丸ボタンをタップ
    func ovalBtnTapped(sender:UIButton){
        //丸を描く
        let oval = MyShapeLayer()
        oval.frame = CGRect(x:30,y:30,width:80,height:80)
        oval.drawOval(lineWidth:1)
        self.view.layer.addSublayer(oval)
    }
    //四角ボタンをタップ
    func rectBtnTapped(sender:UIButton){
        //四角を描く
        let rect = MyShapeLayer()
        rect.frame = CGRect(x:40,y:40,width:50,height:50)
        rect.drawRect(lineWidth:1)
        self.view.layer.addSublayer(rect)
    }
      
ボタンを押したら図形が生成されるようになりました。
ここで生成されたものは自由に動かせるはずです。
これだけでもめちゃくちゃ使い道ありそうです。
図形を拡大縮小しよう
拡大縮小ができるようにしていきます。
まずはUIGestureRecognizerDelegateを書いておきます
class ViewController: UIViewController ,UIGestureRecognizerDelegate{
              /*   ~~    */
}
      
ピンチイン・ピンチアウトを取得するジェスチャーを作成します。
        //ピンチ
        let pinch = UIPinchGestureRecognizer()
        pinch.addTarget(self,action:#selector(ViewController.pinchGesture(sender:)))
        pinch.delegate = self
        self.view.addGestureRecognizer(pinch)
      これで2本指でキュッキュするとイベントがとれます。
ピンチインアウトをした際のアクションを追加します。
func pinchGesture(sender:UIPinchGestureRecognizer){
       //ピンチインアウトをした時によばれる
}
      
現在のスケールとピンチ後のスケールをいれておく変数を用意しておきます。
class ViewController: UIViewController ,UIGestureRecognizerDelegate{
    private var beginGestureScale:CGFloat!
    private var effectiveScale:CGFloat!
    /*  ~~  */
}
      
effectiveScaleの初期値は0にしておきます
    override func viewDidLoad() {
        super.viewDidLoad()
        
        effectiveScale = 1.0
        /*   ~~   */
    }
      
ピンチが始まった時に、現在のスケールをbeginGestureScaleにいれておきます
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if(gestureRecognizer.isKind(of:UIPinchGestureRecognizer.self)){
            beginGestureScale = effectiveScale
        }
        return true
    }
      この判定はおそらくピンチとスワイプとなんやらと色々ある中でどのジェスチャーかを判定するもの、であってると思います。(ちょっと自信ない)
先ほどのピンチインアウトした時に呼ばれるアクションの中にレイヤーを変形するコードをかきます。
    func pinchGesture(sender:UIPinchGestureRecognizer){
        effectiveScale = beginGestureScale * sender.scale
        //選択されてるやつだけ
        if (selectLayer != nil){
            selectLayer.setAffineTransform(CGAffineTransform(scaleX: effectiveScale,y:effectiveScale))
        }
    }
      
選択されているやつだけを拡大縮小しています。
これで拡大縮小ができるようになります。
とりあえずレイヤーを拡大しているだけなので、拡大しても線の太さを変えないだとかをしようと思ったらもう少し考えないとダメですね。
それでは最後に全体コードのっけて終わりにします。
全体コード
ViewController.swift
//
//  ViewController.swift
//  CALayerTest
//
//  Created by Reo on 2016/12/12.
//  Copyright © 2016年 Reo. All rights reserved.
//
import UIKit
class ViewController: UIViewController ,UIGestureRecognizerDelegate{
    
    private var selectLayer:CALayer!
    private var touchLastPoint:CGPoint!
    
    private var beginGestureScale:CGFloat!
    private var effectiveScale:CGFloat!
    override func viewDidLoad() {
        super.viewDidLoad()
        
        effectiveScale = 1.0
        
        let width = self.view.bounds.width
        let height = self.view.bounds.height
        
        //丸を生成するボタン
        let ovalBtn = UIButton()
        ovalBtn.frame = CGRect(x:0,y:0,width:100,height:50)
        ovalBtn.center = CGPoint(x:width / 3,y:height - 30)
        ovalBtn.addTarget(self, action: #selector(ViewController.ovalBtnTapped(sender:)), for: .touchUpInside)
        ovalBtn.setTitle("丸",for:.normal)
        ovalBtn.backgroundColor = UIColor.green
        self.view.addSubview(ovalBtn)
        
        //四角を生成するボタン
        let rectBtn = UIButton()
        rectBtn.frame = CGRect(x:0,y:0,width:100,height:50)
        rectBtn.center = CGPoint(x:width * 2 / 3,y:height - 30)
        rectBtn.addTarget(self, action: #selector(ViewController.rectBtnTapped(sender:)), for: .touchUpInside)
        rectBtn.setTitle("四角",for:.normal)
        rectBtn.backgroundColor = UIColor.red
        self.view.addSubview(rectBtn)
        
        //ピンチ
        let pinch = UIPinchGestureRecognizer()
        pinch.addTarget(self,action:#selector(ViewController.pinchGesture(sender:)))
        pinch.delegate = self
        self.view.addGestureRecognizer(pinch)
        
    }
    
    
    /************* pinch ***************/
    func pinchGesture(sender:UIPinchGestureRecognizer){
        effectiveScale = beginGestureScale * sender.scale
        //選択されてるやつだけ
        if (selectLayer != nil){
            selectLayer.setAffineTransform(CGAffineTransform(scaleX: effectiveScale,y:effectiveScale))
        }
    }
    
    func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
        if(gestureRecognizer.isKind(of:UIPinchGestureRecognizer.self)){
            beginGestureScale = effectiveScale
        }
        return true
    }
    /************** Button Tapped ***********/
    func ovalBtnTapped(sender:UIButton){
        //丸を描く
        let oval = MyShapeLayer()
        oval.frame = CGRect(x:30,y:30,width:80,height:80)
        oval.drawOval(lineWidth:1)
        self.view.layer.addSublayer(oval)
    }
    func rectBtnTapped(sender:UIButton){
        //四角を描く
        let rect = MyShapeLayer()
        rect.frame = CGRect(x:40,y:40,width:50,height:50)
        rect.drawRect(lineWidth:1)
        self.view.layer.addSublayer(rect)
    }
    
    
    /************** Touch Action ****************/
    func hitLayer(touch:UITouch) -> CALayer{
        var touchPoint:CGPoint = touch.location(in:self.view)
        touchPoint = self.view.layer.convert(touchPoint, to: self.view.layer.superlayer)
        return self.view.layer.hitTest(touchPoint)!
    }
    func selectLayerFunc(layer:CALayer?) {
        if((layer == self.view.layer) || (layer == nil)){
            selectLayer = nil
            return
        }
        selectLayer = layer
    }
    
    //タッチをした時
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        //すでに選択されているレイヤーがあるかもしれないのでnilにしておく
        selectLayer = nil
        //タッチを取得
        let touch:UITouch = touches.first!
        //タッチした場所にあるレイヤーを取得
        let layer:CALayer = hitLayer(touch: touch)
        //タッチされた座標を取得
        let touchPoint:CGPoint = touch.location(in: self.view)
        //最後にタッチされた場所に座標を入れて置く
        touchLastPoint = touchPoint
        //選択されたレイヤーをselectLayerにいれる
        self.selectLayerFunc(layer:layer)
    }
    
    //タッチが動いた時
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        let touch:UITouch = touches.first!
        let touchPoint:CGPoint = touch.location(in:self.view)
        //直前の座標との差を取得
        let touchOffsetPoint:CGPoint = CGPoint(x:touchPoint.x - touchLastPoint.x,
                                               y:touchPoint.y - touchLastPoint.y)
        touchLastPoint = touchPoint
        
        if (selectLayer != nil){
            //hitしたレイヤーがあった場合
            let px:CGFloat = selectLayer.position.x
            let py:CGFloat = selectLayer.position.y
            //レイヤーを移動させる
            CATransaction.begin()
            CATransaction.setDisableActions(true)
            selectLayer.position = CGPoint(x:px + touchOffsetPoint.x,y:py + touchOffsetPoint.y)
            selectLayer.borderWidth = 3.0
            selectLayer.borderColor = UIColor.green.cgColor
            CATransaction.commit()
        }
    }
    //タッチを終えた時
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        if(selectLayer != nil){
            selectLayer.borderWidth = 0
        }
    }
    
    //タッチがキャンセルされた時
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        if(selectLayer != nil){
            selectLayer.borderWidth = 0
        }
    }
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }
}
      
MyShapeLayer.swift
//
//  MyShapeLayer.swift
//  CALayerTest
//
//  Created by Reo on 2016/12/13.
//  Copyright © 2016年 Reo. All rights reserved.
//
import UIKit
class MyShapeLayer: CALayer {
    func drawRect(lineWidth:CGFloat){
        let rect = CAShapeLayer()
        rect.strokeColor = UIColor.black.cgColor
        rect.fillColor = UIColor.clear.cgColor
        rect.lineWidth = lineWidth
        rect.path = UIBezierPath(rect:CGRect(x:0,y:0,width:self.frame.width,height:self.frame.height)).cgPath
        self.addSublayer(rect)
    }
    
    func drawOval(lineWidth:CGFloat){
        let ovalShapeLayer = CAShapeLayer()
        ovalShapeLayer.strokeColor = UIColor.blue.cgColor
        ovalShapeLayer.fillColor = UIColor.clear.cgColor
        ovalShapeLayer.lineWidth = lineWidth
        ovalShapeLayer.path = UIBezierPath(ovalIn: CGRect(x:0, y:0, width:self.frame.width, height: self.frame.height)).cgPath
        self.addSublayer(ovalShapeLayer)
    }
}
      
ようやっと画面キャプチャでgifを作成すると良いことに気づきました。
やっぱり動きがあるといいですなぁ。
それではでは。
参考リンク
追記
(追記日: 2018-04-13)
一応Swift4に対応したものをgistにあげました。
と言っても@objcをつけただけなんですけどね。 あとdelegateのところをextensionに移しただけです。
Swift3からSwift4へは簡単に移行できて楽チン。