-->

How to draw a circle path with color gradient stro

2019-01-07 23:11发布

问题:

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?

回答1:

Not really, but you can just stroke a series of arcs in different colors:

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 = CGFloat(3)          { didSet { setNeedsDisplay(bounds) } }

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

    @IBInspectable var strokeColor: NSColor = 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: NSLineCapStyle = .squareLineCapStyle      { didSet { setNeedsDisplay(bounds) } }

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

        let gradations = 255

        let startAngle = -endAngle + maxAngle
        let center = NSPoint(x: bounds.origin.x + bounds.size.width / 2, y: bounds.origin.y + bounds.size.height / 2)
        let radius = (min(bounds.size.width, bounds.size.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
        }

    }

}



回答2:

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)


回答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.



回答4:

Unfortunately not. There are only linear and radial gradients; no angular gradient support is available at the CG layer (or above).

This is quite annoying as Apple has started using these gradients quite heavily, for example in the Watch Activities circles, or in the Bedtime area of the timer app.

There are hacks where you draw thousands of lines from the outer edge of your circle to the center, and then mask them out to your shape. This works but is quite slow (frame drops when scrolling on-screen, when I tried it).

SpriteKit looks most promising: SKShader has some useful uniforms (path length & current distance along path) - dropping an SKView into your app is easy. (My designer dropped the gradient request before I had a chance to try this!)

Alternatively, you could use Metal (MTKView) or Core Image (CIFilter) and write your own shader. Unfortunately using Metal will mean no more working in the iOS Simulator, so CI would be the more realistic solution of the two.