[macOS][Swift4.2] CAShapeLayerを使ったいろんな形のプログレス

いろんな形のプログレス表示を `CAShapeLayer` を使って実装してみた。



* 円形
  ![circle.gif](https://qiita-image-store.s3.amazonaws.com/0/156085/04ab5673-48a9-1bb2-e370-ca497254fd9c.gif)

* 角丸
  ![rounded.gif](https://qiita-image-store.s3.amazonaws.com/0/156085/cc83b9bc-d6f9-f997-f18e-b5d8ffe388b8.gif)

* ベル（イメージ）
  ![bell.gif](https://qiita-image-store.s3.amazonaws.com/0/156085/58540942-7298-ad53-34bd-ad034efd6451.gif)

* 手書き
![freehand.gif](https://qiita-image-store.s3.amazonaws.com/0/156085/1ea46b1f-8f10-edd2-84ee-e3f78478926f.gif)


サンプルコードは以下に配置。
https://github.com/atsushijike/StrokeLayer

* Xcode10.1
* Swift4.2

# ざっくり
* `CAShapeLayer.path` に形状をセットする
* `CAShapeLayer.strokeEnd` に値をセットしてプログレスを表現する
* `CAShapeLayer.transform` で回転を加えることにより開始位置を調整する

# 初期化
```Swift:View.swift
    private let shapeLayer = CAShapeLayer()

    private setup() {
        wantsLayer = true

        // 線の幅
        shapeLayer.lineWidth = 7
        // 線端の処理
        shapeLayer.lineCap = CAShapeLayerLineCap.round
        // 線の色
        shapeLayer.strokeColor = NSColor.red.cgColor
        shapeLayer.fillColor = NSColor.clear.cgColor
        layer?.addSublayer(shapeLayer)
    }
```

線端の処理 `lineCap` はデフォルトで `.butt` でパツっと切られるため `.round` に変更している。
[Line Capの詳細](https://developer.apple.com/documentation/quartzcore/cashapelayer/1521905-linecap#1965770?language=objc)

# 形状
`CAShapeLayer.path` に `CGPath` をセットすることで形状を表現する。
円形や角丸、星型、パスならなんでもOK。

```Swift:AppDelegate.swift
    @IBAction func popUpMenuSelected(_ sender: NSPopUpButton) {
        guard let tag = sender.selectedItem?.tag, let style = Style(rawValue: tag) else { return }
        
        var path: CGPath?
        let size: CGFloat = 100
        switch style {
        case .circle:
            // 円形
            path = CGPath.circle(radius: (size / 2))
        case .rounded:
            // 角丸
            path = CGPath.rounded(rect: CGRect(x: -(size / 2), y: -(size / 2), width: size, height: size), corner: 12)
        case .bell:
            // ベル（イメージ）
            path = CGPath.image(name: "bell", withExtension: "svg")
        case .freehand:
            // 手書き
            let canvasPath = canvasView.path
            let boundingBox = canvasPath.boundingBox
            // rotate & translate
            var transform = CGAffineTransform(rotationAngle: CGFloat.pi / 180 * -90)
            transform = transform.translatedBy(x: -boundingBox.midX, y: -boundingBox.midY)
            path = canvasPath.copy(using: &transform)
        }
        view.path = path
    }
```

### 円形

```Swift:CGPath+Extension.swift
    // 円形
    class func circle(radius: CGFloat) -> CGPath? {
        let path: CGMutablePath = CGMutablePath()
        path.addArc(center: .zero, radius: radius, startAngle: 0, endAngle: CGFloat.pi  * 2, clockwise: true)
        return path
    }
```

### 角丸
`CGPath.addRoundedRect(in:, cornerWidth:, cornerHeight:)` は反時計回りになっているため、 `CGAffineTransform(scaleX:, y:)` を使って反転している。
但し、Transform は macOS 10.14 でしか通用しないため 10.13以前は、else の泥臭いコードになりますw

```Swift:CGPath+Extension.swift
    // 角丸
    class func rounded(rect: CGRect, corner: CGFloat) -> CGPath? {
        let path = CGMutablePath()
        if #available(macOS 10.14, *) {
            path.addRoundedRect(in: rect, cornerWidth: corner, cornerHeight: corner)
            // clockwise
            var scale = CGAffineTransform(scaleX: 1.0, y: -1.0)
            return path.copy(using: &scale)
        } else {
            path.move(to: CGPoint(x: rect.maxX, y: rect.midY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + corner))
            path.addCurve(to: CGPoint(x: rect.maxX - corner, y: rect.minY), control1: CGPoint(x: rect.maxX, y: rect.minY + corner / 2), control2: CGPoint(x: rect.maxX - corner / 2, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.minX + corner, y: rect.minY))
            path.addCurve(to: CGPoint(x: rect.minX, y: rect.minY + corner), control1: CGPoint(x: rect.minX + corner / 2, y: rect.minY), control2: CGPoint(x: rect.minX, y: rect.minY + corner / 2))
            path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - corner))
            path.addCurve(to: CGPoint(x: rect.minX + corner, y: rect.maxY), control1: CGPoint(x: rect.minX, y: rect.maxY - corner / 2), control2: CGPoint(x: rect.minX + corner / 2, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.maxX - corner, y: rect.maxY))
            path.addCurve(to: CGPoint(x: rect.maxX, y: rect.maxY - corner), control1: CGPoint(x: rect.maxX - corner / 2, y: rect.maxY), control2: CGPoint(x: rect.maxX, y: rect.maxY - corner / 2))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - corner))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
            path.closeSubpath()
            return path
        }
    }
```

### ベル（イメージ）
`PocketSVG` を使用してSVGイメージからパスに変換している。
https://github.com/pocketsvg/PocketSVG
90°回転＆センタリングするようtransformをかける。

```Swift:CGPath+Extension.swift
import PocketSVG

...

    // SVGイメージから生成
    class func image(name: String, withExtension: String) -> CGPath? {
        if let url = Bundle.main.url(forResource: name, withExtension: withExtension),
            let bezierPath = SVGBezierPath.pathsFromSVG(at: url).first {
            let path = bezierPath.cgPath
            let boundingBox = path.boundingBox
            // rotate & translate
            var transform = CGAffineTransform(rotationAngle: CGFloat.pi / 180 * 90)
            transform = transform.translatedBy(x: -boundingBox.midX, y: -boundingBox.midY)
            return path.copy(using: &transform)
        }
        return nil
    }
```

### 手書き
#### パスの作成
`CanvasView` でマウスイベントを拾って CGPath を作る。

```Swift:CanvasView.swift
final class CanvasView: NSView {
    var path = CGMutablePath()
    
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
        
        let context = NSGraphicsContext.current?.cgContext
        context?.saveGState()
        context?.setStrokeColor(NSColor.green.cgColor)
        context?.setLineWidth(7)
        context?.addPath(path)
        context?.strokePath()
        context?.restoreGState()
    }
    
    override func mouseDown(with event: NSEvent) {
        path = CGMutablePath()
        path.move(to: convert(event.locationInWindow, from: nil))
        needsDisplay = true
    }
    
    override func mouseDragged(with event: NSEvent) {
        path.addLine(to: convert(event.locationInWindow, from: nil))
        needsDisplay = true
    }
}
```

#### CAShapeLayerにパスをセット
作成した CGPath に-90°回転＆センタリングするようtransformをかけてセットする。

```Swift:AppDelegate.swift
    @IBAction func popUpMenuSelected(_ sender: NSPopUpButton) {
        ...
        switch style {
        ...
        case .freehand:
            let canvasPath = canvasView.path
            let boundingBox = canvasPath.boundingBox
            // rotate & translate
            var transform = CGAffineTransform(rotationAngle: CGFloat.pi / 180 * -90)
            transform = transform.translatedBy(x: -boundingBox.midX, y: -boundingBox.midY)
            path = canvasPath.copy(using: &transform)
        }
        view.path = path
    }
```

# プログレス
`CAShapeLayer.strokeEnd` に0.0~1.0の CGFloat をセットすることでプログレスを表現する。

```Swift:View.swift
    var ratio: CGFloat {
        get {
            return shapeLayer.strokeEnd
        }
        set {
            shapeLayer.strokeEnd = newValue
        }
    }
```

# 開始位置
`CAShapeLayer.transform` に `CATransform3D` をセットして開始位置を調整する。

```Swift:View.swift
    var angle: CGFloat = 0 {
        didSet {
            shapeLayer.transform = CATransform3DMakeRotation(CGFloat.pi / 180 * angle, 0, 0, 1)
        }
    }
```

12時の位置が起点になるよう90°回転させる。

```Swift:AppDelegate.swift
    private func setup() {

        ...

        angle = 90
    }
```