Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

いろんな形のプログレス表示を 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
    }
Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away