How to draw a smooth circle with CAShapeLayer and UIBezierPath?
Asked Answered
P

5

80

I am attempting to draw a stroked circle by using a CAShapeLayer and setting a circular path on it. However, this method is consistently less accurate when rendered to the screen than using borderRadius or drawing the path in a CGContextRef directly.

Here are the results of all three methods: enter image description here

Notice that the third is poorly rendered, especially inside the stroke on the top and bottom.

I have set the contentsScale property to [UIScreen mainScreen].scale.

Here is my drawing code for these three circles. What’s missing to make the CAShapeLayer draw smoothly?

@interface BCViewController ()

@end

@interface BCDrawingView : UIView

@end

@implementation BCDrawingView

- (id)initWithFrame:(CGRect)frame
{
    if ((self = [super initWithFrame:frame])) {
        self.backgroundColor = nil;
        self.opaque = YES;
    }

    return self;
}

- (void)drawRect:(CGRect)rect
{
    [super drawRect:rect];

    [[UIColor whiteColor] setFill];
    CGContextFillRect(UIGraphicsGetCurrentContext(), rect);

    CGContextSetFillColorWithColor(UIGraphicsGetCurrentContext(), NULL);
    [[UIColor redColor] setStroke];
    CGContextSetLineWidth(UIGraphicsGetCurrentContext(), 1);
    [[UIBezierPath bezierPathWithOvalInRect:CGRectInset(self.bounds, 4, 4)] stroke];
}

@end

@interface BCShapeView : UIView

@end

@implementation BCShapeView

+ (Class)layerClass
{
    return [CAShapeLayer class];
}

- (id)initWithFrame:(CGRect)frame
{
    if ((self = [super initWithFrame:frame])) {
        self.backgroundColor = nil;
        CAShapeLayer *layer = (id)self.layer;
        layer.lineWidth = 1;
        layer.fillColor = NULL;
        layer.path = [UIBezierPath bezierPathWithOvalInRect:CGRectInset(self.bounds, 4, 4)].CGPath;
        layer.strokeColor = [UIColor redColor].CGColor;
        layer.contentsScale = [UIScreen mainScreen].scale;
        layer.shouldRasterize = NO;
    }

    return self;
}

@end


@implementation BCViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    UIView *borderView = [[UIView alloc] initWithFrame:CGRectMake(24, 104, 36, 36)];
    borderView.layer.borderColor = [UIColor redColor].CGColor;
    borderView.layer.borderWidth = 1;
    borderView.layer.cornerRadius = 18;
    [self.view addSubview:borderView];

    BCDrawingView *drawingView = [[BCDrawingView alloc] initWithFrame:CGRectMake(20, 40, 44, 44)];
    [self.view addSubview:drawingView];

    BCShapeView *shapeView = [[BCShapeView alloc] initWithFrame:CGRectMake(20, 160, 44, 44)];
    [self.view addSubview:shapeView];

    UILabel *borderLabel = [UILabel new];
    borderLabel.text = @"CALayer borderRadius";
    [borderLabel sizeToFit];
    borderLabel.center = CGPointMake(borderView.center.x + 26 + borderLabel.bounds.size.width/2.0, borderView.center.y);
    [self.view addSubview:borderLabel];

    UILabel *drawingLabel = [UILabel new];
    drawingLabel.text = @"drawRect: UIBezierPath";
    [drawingLabel sizeToFit];
    drawingLabel.center = CGPointMake(drawingView.center.x + 26 + drawingLabel.bounds.size.width/2.0, drawingView.center.y);
    [self.view addSubview:drawingLabel];

    UILabel *shapeLabel = [UILabel new];
    shapeLabel.text = @"CAShapeLayer UIBezierPath";
    [shapeLabel sizeToFit];
    shapeLabel.center = CGPointMake(shapeView.center.x + 26 + shapeLabel.bounds.size.width/2.0, shapeView.center.y);
    [self.view addSubview:shapeLabel];
}


@end

EDIT: For those who cannot see the difference, I've drawn circles on top of each other and zoomed in:

Here I've drawn a red circle with drawRect:, and then drawn an identical circle with drawRect: again in green on top of it. Note the limited bleed of red. Both of these circles are "smooth" (and identical to the cornerRadius implementation):

enter image description here

In this second example, you'll see the issue. I've drawn once using a CAShapeLayer in red, and again on top with a drawRect: implementation of the same path, but in green. Note that you can see a lot more inconsistency with more bleed from the red circle underneath. It's clearly being drawn in a different (and worse) fashion.

enter image description here

Pearsall answered 19/6, 2014 at 21:43 Comment(13)
not sure if it's related to your image capture, but those three circles above appear - to my eye at least - exactly the same.Decastyle
in this line: [UIBezierPath bezierPathWithOvalInRect:CGRectInset(self.bounds, 4, 4)] the self.bound holds points (not pixels) so technically you are creating a non-retina sized curve. if you multiply that size with the [UIScreen mainScreen].scale value, your oval will be perfectly smooth on retina screens.Briscoe
@MaxMacLeod check my edit for the difference.Pearsall
@Briscoe That just makes a smaller circle. Both of my implementations are using that same path, and one looks good and the other does not.Pearsall
In the documentation, it says that rasterization favors speed rather than quality. Could be that you are seeing artefacts of imprecise interpolation/antialiasing.Toor
Have you tried using the edgeAntialiasingMask?Stouthearted
@LeoNatan I've set shouldRasterize = NO on my layer. If I change that to YES it gives a much worse result (very fuzzy all over) as expected.Pearsall
@Stouthearted Yes, I've tried setting it to all edges or to no edges (the default is all edges) and it doesn't make a difference. I think that applies only to the square edges of the layers actual frame as it relates to other nearby layers? It is unrelated to this problem, in any case as far as I can tell.Pearsall
I think you should file a bug report with Apple.Toor
@bcherry, I don't know how you are using that image later, but the logic is that – play with it. that is from a live commercial project, and the issue was identical, so we increased the canvas for the actual dimension of the screen in pixels, and we literally generated an image in size of 640x1136 / 640x960, and the bezier curves are beautifully smooth on the retina screens. I could not help more – that is the idea.Briscoe
@Briscoe I appreciate the suggestion. in my case I'm just trying to simplify things to drop a UIView backed by a CAShapeLayer representing a circle into my view hierarchy, and have it looks good. I'd also like to take advantage of Core Animation on properties of the layer like strokeStart/strokeEnd. For a basic circle, overriding drawRect: isn't a big deal, but a little more complicated.Pearsall
I'm thinking if this could be due to float values when calculating the center and how they are handled in the views, after you assigned the center try to reset the shape view frame by calling CGREctIntegral on the same frame.Stouthearted
@Stouthearted that's an interesting idea. I tried your suggestion but it didn't make a difference as the view's frame was already integral (20, 160; 44, 44). That made the center (42, 182), also integral.Pearsall
N
71

Who knew there are so many ways to draw a circle?

TL;DR: If you want to use CAShapeLayer and still get smooth circles, you'll need to use shouldRasterize and rasterizationScale carefully.

Original

enter image description here

Here's your original CAShapeLayer and a diff from the drawRect version. I made a screenshot off my iPad Mini with Retina Display, then massaged it in Photoshop, and blew it up to 200%. As you can clearly see, the CAShapeLayer version has visible differences, especially on the left and right edges (darkest pixels in the diff).

Rasterize at screen scale

enter image description here

Let's rasterize at screen scale, which should be 2.0 on retina devices. Add this code:

layer.rasterizationScale = [UIScreen mainScreen].scale;
layer.shouldRasterize = YES;

Note that rasterizationScale defaults to 1.0 even on retina devices, which accounts for the fuzziness of default shouldRasterize.

The circle is now a little smoother, but the bad bits (darkest pixels in the diff) have moved to the top and bottom edges. Not appreciably better than no rasterizing!

Rasterize at 2x screen scale

enter image description here

layer.rasterizationScale = 2.0 * [UIScreen mainScreen].scale;
layer.shouldRasterize = YES;

This rasterizes the path at 2x screen scale, or up to 4.0 on retina devices.

The circle is now visibly smoother, the diffs are much lighter and spread out evenly.

I also ran this in Instruments: Core Animation and didn't see any major differences in the Core Animation Debug Options. However it may be slower since it's downscaling not just blitting an offscreen bitmap to the screen. You may also need to temporarily set shouldRasterize = NO while animating.

What doesn't work

  • Set shouldRasterize = YES by itself. On retina devices, this looks fuzzy because rasterizationScale != screenScale.

  • Set contentScale = screenScale. Since CAShapeLayer doesn't draw into contents, whether or not it is rasterizing, this doesn't affect the rendition.

Credit to Jay Hollywood of Humaan, a sharp graphic designer who first pointed it out to me.

Najera answered 11/8, 2014 at 8:33 Comment(4)
Thanks for the answer! This certainly seems to provide a decent workaround for some cases. Do you know is @Stacked is correct, that CAShapeLayer renders a lower-fidelity copy due to optimizations for animation? It does seem like direct drawing manages to draw the right thing at just 2x scale, so upping the scale will workaround the issue but ultimately doesn't fix the underlying problem with CAShapeLayer.Pearsall
I'm pretty sure CAShapeLayer is optimised for fast drawing and animation. However it still has so other advantages that I prefer using it over drawRect: resolution independence (just wait until Apple produces a 4x retina display), less memory, transformable without distortion, flexible rasterization, etc.Najera
Re: flexible rasterization. You can determine ad hoc whether to rasterize or not, or even at what level e.g. in a layer that composes several CAShapeLayer.Najera
I suspect Apple uses a high flatness for CAShapeLayer rendition. You can sometimes see this in non-retina displays: the rendition looks like a polygon. The fix then would be to do the flattening yourself. See e.g. ps.missouri.edu/ps2/support/tutorialfolder/flatness/index.htmlNajera
S
9

Ah, i ran into the same problem some time ago (it was still iOS 5 then iirc), and I wrote the following comment in the code:

/*
    ShapeLayer
    ----------
    Fixed equivalent of CAShapeLayer. 
    CAShapeLayer is meant for animatable bezierpath 
    and also doesn't cache properly for retina display. 
    ShapeLayer converts its path into a pixelimage, 
    honoring any displayscaling required for retina. 
*/

A filled circle underneath a circleshape would bleed its fillcolor. Depending on the colors this would be very noticeable. And during userinteraction the shape would render even worse, which let me to conclude that the shapelayer would always render with a scalefactor of 1.0, regardless of the layer scalefactor, because it is meant for animation purposes.

i.e. you only use a CAShapeLayer if you have a specific need for animatable changes to the shape of the bezierpath, not to any of the other properties that are animatable through the usual layer properties.

I eventually decided to write a simple ShapeLayer that would cache its own result, but you might try implementing the displayLayer: or the drawLayer:inContext:

Something like:

- (void)displayLayer:(CALayer *)layer
{
    UIImage *image = nil;

    CGContextRef context = UIImageContextBegin(layer.bounds.size, NO, 0.0);
    if (context != nil)
    {
        [layer renderInContext:context];
        image = UIImageContextEnd();
    }

    layer.contents = image;
}

I haven't tried that, but would be interesting to know the result...

Stacked answered 1/7, 2014 at 11:55 Comment(1)
This makes a lot of sense. I've noticed that it doesn't matter what I set contentsScale to, it still looks the same. I tried your displayLayer implementation. It didn't make a difference, unfortunately. I suspect that you're right about there being animation-focused drawing optimizations for CAShapeLayer. I'll continue to use custom drawing for my non-animated layers.Pearsall
M
4

I know this is an older question, but for those who are trying to work in the drawRect method and still having trouble, one small tweak that helped me immensely was using the correct method to fetch the UIGraphicsContext. Using the default:

let newSize = CGSize(width: 50, height: 50)
UIGraphicsBeginImageContext(newSize)
let context = UIGraphicsGetCurrentContext()

would result in blurry circles no matter which suggestion I followed from the other answers. What finally did it for me was realizing that the default method for getting an ImageContext sets the scaling to non-retina. To get an ImageContext for a retina display you need to use this method:

let newSize = CGSize(width: 50, height: 50)
UIGraphicsBeginImageContextWithOptions(newSize, false, 0)
let context = UIGraphicsGetCurrentContext()

from there using the normal drawing methods worked fine. Setting the last option to 0 will tell the system to use the scaling factor of the device’s main screen. The middle option false is used to tell the graphics context whether or not you'll be drawing an opaque image (true means the image will be opaque) or one that needs an alpha channel included for transparencies. Here are the appropriate Apple Docs for more context: https://developer.apple.com/reference/uikit/1623912-uigraphicsbeginimagecontextwitho?language=objc

Metallize answered 21/11, 2016 at 18:15 Comment(0)
A
1

I guess CAShapeLayer is backed by a more performant way of rendering its shapes and takes some shortcuts. Anyway CAShapeLayer can be a little bit slow on the main thread. Unless you need to animate between different paths I would suggest render asynchronously to a UIImage on a background thread.

Audiology answered 1/7, 2014 at 12:5 Comment(0)
M
-1

Use this method to draw UIBezierPath

/*********draw circle where double tapped******/
- (UIBezierPath *)makeCircleAtLocation:(CGPoint)location radius:(CGFloat)radius
{
    self.circleCenter = location;
    self.circleRadius = radius;

    UIBezierPath *path = [UIBezierPath bezierPath];
    [path addArcWithCenter:self.circleCenter
                    radius:self.circleRadius
                startAngle:0.0
                  endAngle:M_PI * 2.0
                 clockwise:YES];

    return path;
}

And draw like this

CAShapeLayer *shapeLayer = [CAShapeLayer layer];
    shapeLayer.path = [[self makeCircleAtLocation:location radius:50.0] CGPath];
    shapeLayer.strokeColor = [[UIColor whiteColor] CGColor];
    shapeLayer.fillColor = nil;
    shapeLayer.lineWidth = 3.0;

    // Add CAShapeLayer to our view

    [gesture.view.layer addSublayer:shapeLayer];
Macerate answered 1/7, 2014 at 12:44 Comment(1)
I've tried this method of making a circle as well as using a rounded rect path. Neither solves the issue with pixellation along the edges of a path drawn with CAShapeLayer.Pearsall

© 2022 - 2024 — McMap. All rights reserved.