いろんな形のプログレス表示を CAShapeLayer
を使って実装してみた。
サンプルコードは以下に配置。
https://github.com/atsushijike/StrokeLayer
- Xcode10.1
- Swift4.2
ざっくり
-
CAShapeLayer.path
に形状をセットする -
CAShapeLayer.strokeEnd
に値をセットしてプログレスを表現する -
CAShapeLayer.transform
で回転を加えることにより開始位置を調整する
初期化
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.path
に CGPath
をセットすることで形状を表現する。
円形や角丸、星型、パスならなんでもOK。
@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
}
円形
// 円形
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
// 角丸
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をかける。
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 を作る。
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をかけてセットする。
@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 をセットすることでプログレスを表現する。
var ratio: CGFloat {
get {
return shapeLayer.strokeEnd
}
set {
shapeLayer.strokeEnd = newValue
}
}
開始位置
CAShapeLayer.transform
に CATransform3D
をセットして開始位置を調整する。
var angle: CGFloat = 0 {
didSet {
shapeLayer.transform = CATransform3DMakeRotation(CGFloat.pi / 180 * angle, 0, 0, 1)
}
}
12時の位置が起点になるよう90°回転させる。
private func setup() {
...
angle = 90
}