LoginSignup
28
21

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-12-10

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

  • 円形
    circle.gif

  • 角丸
    rounded.gif

  • ベル(イメージ)
    bell.gif

  • 手書き
    freehand.gif

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

  • Xcode10.1
  • Swift4.2

ざっくり

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

初期化

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の詳細

形状

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

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
    }

円形

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

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をかける。

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 を作る。

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をかけてセットする。

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 をセットすることでプログレスを表現する。

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

開始位置

CAShapeLayer.transformCATransform3D をセットして開始位置を調整する。

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

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

AppDelegate.swift
    private func setup() {

        ...

        angle = 90
    }
28
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
21