How to draw a circle path with color gradient stroke

In macOS 10.14 and later (as well as in iOS 12 and later), you can create a CAGradientLayer with a type of .conic, and then mask it with a circular arc. For example, for macOS:

class GradientArcView: NSView {
    var startColor: NSColor = .white { didSet { setNeedsDisplay(bounds) } }
    var endColor:   NSColor = .blue  { didSet { setNeedsDisplay(bounds) } }
    var lineWidth:  CGFloat = 3      { didSet { setNeedsDisplay(bounds) } }

    private let gradientLayer: CAGradientLayer = {
        let gradientLayer = CAGradientLayer()
        gradientLayer.type = .conic
        gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
        gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
        return gradientLayer
    }()

    override init(frame: CGRect = .zero) {
        super.init(frame: frame)

        configure()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        configure()
    }

    override func layout() {
        super.layout()

        updateGradient()
    }
}

private extension GradientArcView {
    func configure() {
        wantsLayer = true
        layer?.addSublayer(gradientLayer)
    }

    func updateGradient() {
        gradientLayer.frame = bounds
        gradientLayer.colors = [startColor, endColor].map { $0.cgColor }

        let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
        let path = CGPath(ellipseIn: bounds.insetBy(dx: bounds.width / 2 - radius, dy: bounds.height / 2 - radius), transform: nil)
        let mask = CAShapeLayer()
        mask.fillColor = NSColor.clear.cgColor
        mask.strokeColor = NSColor.white.cgColor
        mask.lineWidth = lineWidth
        mask.path = path
        gradientLayer.mask = mask
    }
}

Or, in iOS:

@IBDesignable
class GradientArcView: UIView {
    @IBInspectable var startColor: UIColor = .white { didSet { setNeedsLayout() } }
    @IBInspectable var endColor:   UIColor = .blue  { didSet { setNeedsLayout() } }
    @IBInspectable var lineWidth:  CGFloat = 3      { didSet { setNeedsLayout() } }

    private let gradientLayer: CAGradientLayer = {
        let gradientLayer = CAGradientLayer()
        gradientLayer.type = .conic
        gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
        gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
        return gradientLayer
    }()

    override init(frame: CGRect = .zero) {
        super.init(frame: frame)

        configure()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)

        configure()
    }

    override func layoutSubviews() {
        super.layoutSubviews()

        updateGradient()
    }
}

private extension GradientArcView {
    func configure() {
        layer.addSublayer(gradientLayer)
    }

    func updateGradient() {
        gradientLayer.frame = bounds
        gradientLayer.colors = [startColor, endColor].map { $0.cgColor }

        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
        let path = UIBezierPath(arcCenter: center, radius: radius, startAngle: 0, endAngle: 2 * .pi, clockwise: true)
        let mask = CAShapeLayer()
        mask.fillColor = UIColor.clear.cgColor
        mask.strokeColor = UIColor.white.cgColor
        mask.lineWidth = lineWidth
        mask.path = path.cgPath
        gradientLayer.mask = mask
    }
}

In earlier OS versions you have to do something manual, such as stroking a series of arcs in different colors. For example, in macOS:

import Cocoa

/// This draws an arc, of length `maxAngle`, ending at `endAngle. This is `@IBDesignable`, so if you
/// put this in a separate framework target, you can use this class in Interface Builder. The only
/// property that is not `@IBInspectable` is the `lineCapStyle` (as IB doesn't know how to show that).
///
/// If you want to make this animated, just use a `CADisplayLink` update the `endAngle` property (and
/// this will automatically re-render itself whenever you change that property).

@IBDesignable
class GradientArcView: NSView {

    /// Width of the stroke.

    @IBInspectable var lineWidth: CGFloat = 3             { didSet { setNeedsDisplay(bounds) } }

    /// Color of the stroke (at full alpha, at the end).

    @IBInspectable var strokeColor: NSColor = .blue       { didSet { setNeedsDisplay(bounds) } }

    /// Where the arc should end, measured in degrees, where 0 = "3 o'clock".

    @IBInspectable var endAngle: CGFloat = 0              { didSet { setNeedsDisplay(bounds) } }

    /// What is the full angle of the arc, measured in degrees, e.g. 180 = half way around, 360 = all the way around, etc.

    @IBInspectable var maxAngle: CGFloat = 360            { didSet { setNeedsDisplay(bounds) } }

    /// What is the shape at the end of the arc.

    var lineCapStyle: NSBezierPath.LineCapStyle = .square { didSet { setNeedsDisplay(bounds) } }

    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)

        let gradations = 255

        let startAngle = -endAngle + maxAngle
        let center = NSPoint(x: bounds.midX, y: bounds.midY)
        let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
        var angle = startAngle

        for i in 1 ... gradations {
            let percent = CGFloat(i) / CGFloat(gradations)
            let endAngle = startAngle - percent * maxAngle
            let path = NSBezierPath()
            path.lineWidth = lineWidth
            path.lineCapStyle = lineCapStyle
            path.appendArc(withCenter: center, radius: radius, startAngle: angle, endAngle: endAngle, clockwise: true)
            strokeColor.withAlphaComponent(percent).setStroke()
            path.stroke()
            angle = endAngle
        }
    }
}

enter image description here

Leave a Comment