How to draw a circle path with color gradient stroke
I want to draw a circle with color gradient stroke like the following picture, on both iOS and macOS:
Is it possible to implement with CAShapeLayer
or NSBezierPath
/CGPath
? Or any other ways?
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
}
}
}
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)
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.