Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to draw a smooth circle with CAShapeLayer and UIBezierPath?

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

like image 425
bcherry Avatar asked Jun 19 '14 21:06

bcherry


People also ask

What is UIBezierPath?

A UIBezierPath object combines the geometry of a path with attributes that describe the path during rendering. You set the geometry and attributes separately and can change them independent of one another. After you have the object configured the way you want it, you can tell it to draw itself in the current context.

How do you draw a circle in Swift?

Enter Swift as Language and choose Storyboard as User Interface, Choose Next. Select File -> New File -> iOS -> Cocoa Touch Class. Name the class CircleView with a subclass of UIView. This class will contain the views where the circles will be drawn.

What is Cashapelayer in Swift?

A layer that draws a cubic Bezier spline in its coordinate space.


2 Answers

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.

like image 100
Glen Low Avatar answered Sep 22 '22 11:09

Glen Low


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

like image 25
32Beat Avatar answered Sep 23 '22 11:09

32Beat