How to draw a circle path with color gradient stroke
Asked Answered
O

4

15

I want to draw a circle with color gradient stroke like the following picture, on both iOS and macOS:

Circle path

Is it possible to implement with CAShapeLayer or NSBezierPath/CGPath? Or any other ways?

Ocular answered 22/10, 2016 at 3:36 Comment(2)
if you want like this then I will help you #20631153Comb
@Jecky Thank you for your comment. I checked your link and the CAGradientLayer API and found that it only supports linear gradient. But it seems hard to implement the gradient showed in above picture. Please help!Ocular
S
25

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

Seessel answered 22/10, 2016 at 8:49 Comment(3)
This is a great idea. Thank you. I'll give it a try.Ocular
The answer is about an implementation in macOS. Can I just replace NSView with UIView in iOS?Selfabsorbed
@HansBondoka - NSView with UIView, NSBezierPath with UIBezierPath, layout with layoutSubviews, etc. Lots of tiny little syntax differences, but they’re basically the same. I’ve revised my answer, adding the iOS rendition of the first, conic gradient layer implementation. You can see the parallels. The translation of the latter example is also pretty simple, but I’ll leave that to the reader. The original question, while tagged with both platforms, mentioned NSBezierPath, etc., so I focused on the macOS renditions. Hopefully Marzipan will put this silliness behind us.Seessel
D
14

enter image description here

Here is some code that worked for me. There's animations in it, but you can use the same principle to make a strokeEnd with a gradient.

A. Created a custom view 'Donut' and put this in the header:

@interface Donut : UIView
@property UIColor * fromColour;
@property UIColor * toColour;
@property UIColor * baseColour;
@property float lineWidth;
@property float duration;
-(void)layout;
-(void)animateTo:(float)percentage;

B. Then did the basic view setup and wrote these two methods:

-(void)layout{

    //vars
    float dimension = self.frame.size.width;

    //1. layout views

    //1.1 layout base track
    UIBezierPath * donut = [UIBezierPath bezierPathWithOvalInRect:CGRectMake(lineWidth/2, lineWidth/2, dimension-lineWidth, dimension-lineWidth)];
    CAShapeLayer * baseTrack = [CAShapeLayer layer];
    baseTrack.path = donut.CGPath;
    baseTrack.lineWidth = lineWidth;
    baseTrack.fillColor = [UIColor clearColor].CGColor;
    baseTrack.strokeStart = 0.0f;
    baseTrack.strokeEnd = 1.0f;
    baseTrack.strokeColor = baseColour.CGColor;
    baseTrack.lineCap = kCALineCapButt;
    [self.layer addSublayer:baseTrack];

    //1.2 clipView has mask applied to it
    UIView * clipView = [UIView new];
    clipView.frame =  self.bounds;
    [self addSubview:clipView];

    //1.3 rotateView transforms with strokeEnd
    rotateView = [UIView new];
    rotateView.frame = self.bounds;
    [clipView addSubview:rotateView];

    //1.4 radialGradient holds an image of the colours
    UIImageView * radialGradient = [UIImageView new];
    radialGradient.frame = self.bounds;
    [rotateView addSubview:radialGradient];



    //2. create colours fromColour --> toColour and add to an array

    //2.1 holds all colours between fromColour and toColour
    NSMutableArray * spectrumColours = [NSMutableArray new];

    //2.2 get RGB values for both colours
    double fR, fG, fB; //fromRed, fromGreen etc
    double tR, tG, tB; //toRed, toGreen etc
    [fromColour getRed:&fR green:&fG blue:&fB alpha:nil];
    [toColour getRed:&tR green:&tG blue:&tB alpha:nil];

    //2.3 determine increment between fromRed and toRed etc.
    int numberOfColours = 360;
    double dR = (tR-fR)/(numberOfColours-1);
    double dG = (tG-fG)/(numberOfColours-1);
    double dB = (tB-fB)/(numberOfColours-1);

    //2.4 loop through adding incrementally different colours
    //this is a gradient fromColour --> toColour
    for (int n = 0; n < numberOfColours; n++){
        [spectrumColours addObject:[UIColor colorWithRed:(fR+n*dR) green:(fG+n*dG) blue:(fB+n*dB) alpha:1.0f]];
    }


    //3. create a radial image using the spectrum colours
    //go through adding the next colour at an increasing angle

    //3.1 setup
    float radius = MIN(dimension, dimension)/2;
    float angle = 2 * M_PI/numberOfColours;
    UIBezierPath * bezierPath;
    CGPoint center = CGPointMake(dimension/2, dimension/2);

    UIGraphicsBeginImageContextWithOptions(CGSizeMake(dimension, dimension), true, 0.0);
    UIRectFill(CGRectMake(0, 0, dimension, dimension));

    //3.2 loop through pulling the colour and adding
    for (int n = 0; n<numberOfColours; n++){

        UIColor * colour = spectrumColours[n]; //colour for increment

        bezierPath = [UIBezierPath bezierPathWithArcCenter:center radius:radius startAngle:n * angle endAngle:(n + 1) * angle clockwise:YES];
        [bezierPath addLineToPoint:center];
        [bezierPath closePath];

        [colour setFill];
        [colour setStroke];
        [bezierPath fill];
        [bezierPath stroke];
    }

    //3.3 create image, add to the radialGradient and end
    [radialGradient setImage:UIGraphicsGetImageFromCurrentImageContext()];
    UIGraphicsEndImageContext();



    //4. create a dot to add to the rotating view
    //this covers the connecting line between the two colours

    //4.1 set up vars
    float containsDots = (M_PI * dimension) /*circumference*/ / lineWidth; //number of dots in circumference
    float colourIndex = roundf((numberOfColours / containsDots) * (containsDots-0.5f)); //the nearest colour for the dot
    UIColor * closestColour = spectrumColours[(int)colourIndex]; //the closest colour

    //4.2 create dot
    UIImageView * dot = [UIImageView new];
    dot.frame = CGRectMake(dimension-lineWidth, (dimension-lineWidth)/2, lineWidth, lineWidth);
    dot.layer.cornerRadius = lineWidth/2;
    dot.backgroundColor = closestColour;
    [rotateView addSubview:dot];


    //5. create the mask
    mask = [CAShapeLayer layer];
    mask.path = donut.CGPath;
    mask.lineWidth = lineWidth;
    mask.fillColor = [UIColor clearColor].CGColor;
    mask.strokeStart = 0.0f;
    mask.strokeEnd = 0.0f;
    mask.strokeColor = [UIColor blackColor].CGColor;
    mask.lineCap = kCALineCapRound;

    //5.1 apply the mask and rotate all by -90 (to move to the 12 position)
    clipView.layer.mask = mask;
    clipView.transform = CGAffineTransformMakeRotation(DEGREES_TO_RADIANS(-90.0f));

}

-(void)animateTo:(float)percentage {

    float difference = fabsf(fromPercentage - percentage);
    float fixedDuration = difference * duration;

    //1. animate stroke End
    CABasicAnimation * strokeEndAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
    strokeEndAnimation.duration = fixedDuration;
    strokeEndAnimation.fromValue = @(fromPercentage);
    strokeEndAnimation.toValue = @(percentage);
    strokeEndAnimation.fillMode = kCAFillModeForwards;
    strokeEndAnimation.removedOnCompletion = false;
    strokeEndAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
    [mask addAnimation:strokeEndAnimation forKey:@"strokeEndAnimation"];

    //2. animate rotation of rotateView
    CABasicAnimation * viewRotationAnimation = [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
    viewRotationAnimation.duration = fixedDuration;
    viewRotationAnimation.fromValue = @(DEGREES_TO_RADIANS(360 * fromPercentage));
    viewRotationAnimation.toValue = @(DEGREES_TO_RADIANS(360 * percentage));
    viewRotationAnimation.fillMode = kCAFillModeForwards;
    viewRotationAnimation.removedOnCompletion = false;
    viewRotationAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
    [rotateView.layer addAnimation:viewRotationAnimation forKey:@"viewRotationAnimation"];

    //3. update from percentage
    fromPercentage = percentage;

}

C. Create view:

Donut * donut = [Donut new];
donut.frame = CGRectMake(20, 100, 140, 140);
donut.baseColour = [[UIColor blackColor] colorWithAlphaComponent:0.2f];
donut.fromColour = [UIColor redColor];
donut.toColour = [UIColor blueColor];
donut.lineWidth = 20.0f;
donut.duration = 2.0f;
[donut layout];
[tasteView addSubview:donut];

D. Animate view:

[donut animateTo:0.5f];

E. Explanation:

The Donut view starts off by creating a base track, clipView, rotateView and a radialGradient imageView. It then calculates 360 colours between the two colours you want to use in the donut. It does so by incrementing the rgb values between the colours. Then a radial gradient image is created using those colours and added to the imageView. Because I wanted to use kCALineCapRound, I added a dot to cover up where the two colours meet. The whole thing needs to be rotated by -90 degrees to put it in the 12 O'Clock position. Then a mask is applied to the view, giving it the donut shape.

As the strokeEnd of the mask is changed, the view beneath it 'rotateView' is rotated as well. This gives the impression that the line is growing / shrinking as long as they in are in sync.

You might also need this:

#define DEGREES_TO_RADIANS(x) (M_PI * (x) / 180.0)
Daytoday answered 27/4, 2017 at 21:44 Comment(0)
S
3

Since your path is a circle, what you are asking for amounts to an angular gradient, that is, a sort of pie that changes color as we sweep a radius round the pie. There is no built-in way to do that, but there's a great library that does it for you:

https://github.com/paiv/AngleGradientLayer

The trick is that you draw your angular gradient with its center at the center of your circle, and then put a mask over it so that it appears only where your circle stroke is supposed to be.

Suez answered 24/10, 2016 at 2:38 Comment(1)
This looks like a promising solution! Thank you very much.Ocular
T
2

Use below Code. Tested and works in iOS10+

import UIKit

class MMTGradientArcView: UIView {

    var lineWidth: CGFloat = 3              { didSet { setNeedsDisplay(bounds) } }
    var startColor = UIColor.green          { didSet { setNeedsDisplay(bounds) } }
    var endColor = UIColor.clear            { didSet { setNeedsDisplay(bounds) } }
    var startAngle:CGFloat = 0              { didSet { setNeedsDisplay(bounds) } }
    var endAngle:CGFloat = 360                { didSet { setNeedsDisplay(bounds) } }

    override func draw(_ rect: CGRect) {

        let gradations = 289 //My School Number

        var startColorR:CGFloat = 0
        var startColorG:CGFloat = 0
        var startColorB:CGFloat = 0
        var startColorA:CGFloat = 0

        var endColorR:CGFloat = 0
        var endColorG:CGFloat = 0
        var endColorB:CGFloat = 0
        var endColorA:CGFloat = 0

        startColor.getRed(&startColorR, green: &startColorG, blue: &startColorB, alpha: &startColorA)
        endColor.getRed(&endColorR, green: &endColorG, blue: &endColorB, alpha: &endColorA)

        let startAngle:CGFloat = 0
        let endAngle:CGFloat = 270
        let center = CGPoint(x: bounds.midX, y: bounds.midY)
        let radius = (min(bounds.width, bounds.height) - lineWidth) / 2
        var angle = startAngle

        for i in 1 ... gradations {
            let extraAngle = (endAngle - startAngle) / CGFloat(gradations)
            let currentStartAngle = angle
            let currentEndAngle = currentStartAngle + extraAngle

            let currentR = ((endColorR - startColorR) / CGFloat(gradations - 1)) * CGFloat(i - 1) + startColorR
            let currentG = ((endColorG - startColorG) / CGFloat(gradations - 1)) * CGFloat(i - 1) + startColorG
            let currentB = ((endColorB - startColorB) / CGFloat(gradations - 1)) * CGFloat(i - 1) + startColorB
            let currentA = ((endColorA - startColorA) / CGFloat(gradations - 1)) * CGFloat(i - 1) + startColorA

            let currentColor = UIColor.init(red: currentR, green: currentG, blue: currentB, alpha: currentA)

            let path = UIBezierPath()
            path.lineWidth = lineWidth
            path.lineCapStyle = .round
            path.addArc(withCenter: center, radius: radius, startAngle: currentStartAngle * CGFloat(Double.pi / 180.0), endAngle: currentEndAngle * CGFloat(Double.pi / 180.0), clockwise: true)
            currentColor.setStroke()
            path.stroke()
            angle = currentEndAngle
        }
    }
}
Tricycle answered 5/6, 2019 at 19:1 Comment(1)
In my opinion this is the only real answer (even if it's a tricky way). It also works even if the circular progress exceeds a single rotation (the endAngle might be something like 400, for example).Timoteo

© 2022 - 2024 — McMap. All rights reserved.