How to Draw a Smooth Circle with Cashapelayer and Uibezierpath

How to draw a smooth circle with CAShapeLayer and UIBezierPath?

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

Sample Image

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

Sample Image

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

Sample Image

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.

An easy way to draw a circle using CAShapeLayer

An easy way to draw a circle is to create a CAShapeLayer and add a UIBezierPath.

objective-c

CAShapeLayer *circleLayer = [CAShapeLayer layer];
[circleLayer setPath:[[UIBezierPath bezierPathWithOvalInRect:CGRectMake(50, 50, 100, 100)] CGPath]];

swift

let circleLayer = CAShapeLayer();
circleLayer.path = UIBezierPath(ovalIn: CGRect(x: 50, y: 50, width: 100, height: 100)).cgPath;

After creating the CAShapeLayer we set its path to be a UIBezierPath.

Our UIBezierPath then draws a bezierPathWithOvalInRect. The CGRect we set will effect its size and position.

Now that we have our circle, we can add it to our UIView as a sublayer.

objective-c

[[self.view layer] addSublayer:circleLayer];

swift

view.layer.addSublayer(circleLayer)

Our circle is now visible in our UIView.

Circle

If we wish to customise our circle's color properties we can easily do so by setting the CAShapeLayer's stroke- and fill color.

objective-c

[circleLayer setStrokeColor:[[UIColor redColor] CGColor]];
[circleLayer setFillColor:[[UIColor clearColor] CGColor]];

swift

shapeLayer.strokeColor = UIColor.red.cgColor;
shapeLayer.fillColor = UIColor.clear.cgColor;

Circle_wColors

Additionall properties can be found over at 's documentation on the subject https://developer.apple.com/.../CAShapeLayer_class/index.html.

An issue with CAShapeLayer and UIBezierPath drawing circle counterclockwise

It's easier to think about this in degrees.

  • -0.5 * .pi radians equals -90 degrees
  • 1.5 * .pi radians equals 270 degrees
  • 0 degrees is at the middle-right of the circle

However, if you think about it, -90 and 270 are at the same position on the circle.


clockwise = true:

UIBezierPath(arcCenter: arcCenter, radius: 100, startAngle: -0.5 * .pi, endAngle: 1.5 * .pi, clockwise: true)

clockwise = false:

UIBezierPath(arcCenter: arcCenter, radius: 100, startAngle: -0.5 * .pi, endAngle: 1.5 * .pi, clockwise: false)

So how come clockwise draws the long way, but counterclockwise doesn't? Take a look at this:
gradually increasing arc angles

If you pick two points (or you could say angles) on the circle and gradually increase one of them, you can see how the ring lengthens/shortens based on whether it's going clockwise/counterclockwise. The rings complement each other -- if you put the clockwise ring on top of the counterclockwise one, they fit perfectly together in a circle.

So, when you increase the ending point so that it's the equivalent of the starting point (start: -90, end: 270):

  • the clockwise ring will be full
  • the counterclockwise ring will be empty

whereas when you switch the negatives (start: 90, end: -270):

  • the clockwise ring will be empty
  • the counterclockwise ring will be full

Also, here's a handy extension (thanks @Leo Dabus!) so you don't have to deal with radians anymore:

extension BinaryInteger {
var degreesToRadians: CGFloat { CGFloat(self) * .pi / 180 }
}

extension FloatingPoint {
var degreesToRadians: Self { self * .pi / 180 }
var radiansToDegrees: Self { self * 180 / .pi }
}

/// usage:
UIBezierPath(arcCenter: arcCenter, radius: 100, startAngle: -90.degreesToRadians, endAngle: 270.degreesToRadians, clockwise: true)

Swift draw and RESIZE circle with CAShapeLayer() and UIBezierPath

For the first version of your path code, the radius parameter determines the size of the circle.

In the second version, it draws an oval (which may or may not be a circle) into a rectangular box.

Both let you control the size, just with different parameters.

If you want to vary the size of the arc drawn with init(arcCenter:radius:startAngle:endAngle:clockwise:) then vary the radius parameter.

A circle drawn with

let circularPath = UIBezierPath(arcCenter: center, 
radius: 100,
startAngle: -CGFloat.pi / 2,
endAngle: 2 * CGFloat.pi,
clockwise: true)

Will be twice as big as a circle drawn with

let circularPath = UIBezierPath(arcCenter: center, 
radius: 50,
startAngle: -CGFloat.pi / 2,
endAngle: 2 * CGFloat.pi,
clockwise: true)

(And btw, if the start angle is -π/2, shouldn't the end angle be 3π/2, so the arc is 360° (or 2π) rather than 450°?)

Draw concentric circle using UIBezierPath

Here's exactly how to do it using layers.

There are indeed many advantages to doing it using layers rather than drawing it.

(By "drawing", I mean drawing it in draw#rect.)

To begin with animation is easier; and it is more reusable; and you don't have to concern yourself with the difficulty of "if the view moves/reshapes what exactly should I redraw". Everything is automatic with layers.

So here are the four major things to do when making layers:

1. As always, make the layers, and set their frames in layoutSubviews.

In iOS when you make layers you (A) make them (almost certainly with a lazy var) and (B) at layout time, of course you set the size of the frame.

Those are the two basic steps in using layers in iOS.

Note that you do not make them at layout time, and you do not set the frame at bringup time. You only make them at bringup time and only set the size of the frame at layout time.

I made a UIView, "Thingy" in storyboard and using constraints positioned it and made it 100x100.

I made the background color blue so you can see what is going on. (You'd probably want it to be clear.)

Sample Image

Here it is in the app:

Sample Image

We're going to need the thickness of the ring and of the gap:

class Thingy: UIView {

var thicknessOfOuterRing: CGFloat = 20.0 {
didSet{ setNeedsLayout() }
}

var thicknessOfTheGap: CGFloat = 20.0 {
didSet{ setNeedsLayout() }
}

A critical point to understand is that when you change those values, you will have to resize everything. Right?

That means you add "didSet .. setNeedsLayout" to those properties in the usual way.

(Similarly, if you want to be able to change say the color on the fly, you'd do that to the colors also. Anything you want to be able to "change", you need the "didSet .. setNeedsLayout" pattern.)

There are two simple calculations we will always need, the half-thickness, and the combined fatness of those. Simply use computed variables for these, to vastly simplify your code.

var halfT: CGFloat { return thicknessOfOuterRing / 2.0 }
var both: CGFloat { return thicknessOfOuterRing + thicknessOfTheGap }

OK! So two layers. The ring will be a shape layer, but the blob in the middle can simply be a layer:

private lazy var outerBand: CAShapeLayer = {
let l = CAShapeLayer()
layer.addSublayer(l)
return l
}()

private lazy var centralBlob: CALayer = {
let l = CALayer()
layer.addSublayer(l)
return l
}()

Now of course, you must set their frames at layout time:

override func layoutSubviews() {
super.layoutSubviews()

outerBand.frame = bounds
centralBlob.frame = bounds
}

So that's the basic process of using layers in iOS.

(A) make the layers (almost certainly with a lazy var) and (B) at layout time, of course you set the size of the frame.

2. Set all "fixed" aspects of the layers, when they are made:

What qualities of the layers will never change?

Set those "never-changing" qualities in the lazy var:

private lazy var outerBand: CAShapeLayer = {
let l = CAShapeLayer()
l.fillColor = UIColor.clear.cgColor
l.strokeColor = UIColor.black.cgColor
layer.addSublayer(l)
return l
}()

private lazy var centralBlob: CALayer = {
let l = CALayer()
l.backgroundColor = UIColor.black.cgColor
layer.addSublayer(l)
return l
}()

Once again, anything that never changes, shove it in the lazy vars.

Next ...

3. Set all "changing" aspect of the layers, in layout:

Regarding the central blob. It's very easy to make a coin shape from a square CALayer in iOS, you just do this:

someCALayer.cornerRadius = someCALayer.bounds.width / 2.0

Note that you COULD, and some programmers DO, actually use a shape layer, and go to the bother of making a circular shape and so on. But it's fine to just use a normal layer and simply set the cornerRadius.

Secondly regarding the inner blob. Note that it is simply smaller than the overall unit. In other words the frame of the inner blob has to be shrunk by some amount.

To achieve that use the critical inset(by: UIEdgeInsets call in iOS. Use it often!

You use inset(by: UIEdgeInsets very often in iOS.

Here's an important sub-tip. I really encourage you to use inset(by: UIEdgeInsets( ... where you lugubriously write out "top, left, bottom, right"

The other variation, insetBy#dx#dy tends to lead to confusion; it's unclear if you're doing double the quantity or what.

I personally recommend using the "long form" inset(by: UIEdgeInsets( ... as there is then absolutely no doubt what is happening.

So we are now totally done with the inner dot:

override func layoutSubviews() {
super.layoutSubviews()

outerBand.frame = bounds
outerBand.lineWidth = thicknessOfOuterRing

centralBlob.frame =
bounds.inset(by: UIEdgeInsets(top: both, left: both, bottom: both, right: both))
centralBlob.cornerRadius = centralBlob.bounds.width / 2.0
}

Sample Image

Finally, and this is the heart of your question:

The outer ring is made using a shape layer, not a normal layer.

And shape layers are defined by a path.

Here's the "one surprising trick" that is central to using shape layers and paths in iOS:

4. In iOS you make the paths of shape layers actually in layoutSubviews.

In my experience this confuses the hell out of programmers moving from other platforms to iOS, but that's how it goes down.

So let's make a computing variable that creates the path for us.

Note that you will definitely use the ever-handy inset(by: UIEdgeInsets( ... call.

Don't forget that by the time this is called, you should have already correctly set all the frames involved.

And finally Apple kindly give us the incredibly handy UIBezierPath#ovalIn call so you don't need to fool with arcs and Pi!

So it's this easy ...

var circlePathForCurrentLayout: UIBezierPath {
var b = bounds
b = b.inset(by: UIEdgeInsets(top: halfT, left: halfT, bottom: halfT, right: halfT))
let p = UIBezierPath(ovalIn: b)
return p
}

And then, as it says in bold letters, in iOS you surprisingly set the actual path of a shape layer, in layoutSubviews.

override func layoutSubviews() {
super.layoutSubviews()

outerBand.frame = bounds
outerBand.lineWidth = thicknessOfOuterRing
outerBand.path = circlePathForCurrentLayout.cgPath

centralBlob.frame =
bounds.inset(by: UIEdgeInsets(top: both, left: both, bottom: both, right: both))
centralBlob.cornerRadius = centralBlob.bounds.width / 2.0
}

Sample Image

Recap:

1. As always, make the layers, and set their frames in layoutSubviews.

2. Set all "fixed" aspects of the layers, when they are made.

3. Set all "changing" aspect of the layers, in layoutSubviews.

4. Surprisingly in iOS you make the paths of shape layers actually in layoutSubviews.

And here's the whole thing to drop in:

//  Thingy.swift
// Created for SO on 11/3/19.

import UIKit

class Thingy: UIView {

var thicknessOfOuterRing: CGFloat = 20.0 {
didSet{ setNeedsLayout() }
}

var thicknessOfTheGap: CGFloat = 20.0 {
didSet{ setNeedsLayout() }
}

var halfT: CGFloat { return thicknessOfOuterRing / 2.0 }
var both: CGFloat { return thicknessOfOuterRing + thicknessOfTheGap }

var circlePathForCurrentLayout: UIBezierPath {
var b = bounds
b = b.inset(by:
UIEdgeInsets(top: halfT, left: halfT, bottom: halfT, right: halfT))
let p = UIBezierPath(ovalIn: b)
return p
}

private lazy var outerBand: CAShapeLayer = {
let l = CAShapeLayer()
l.fillColor = UIColor.clear.cgColor
l.strokeColor = UIColor.black.cgColor
layer.addSublayer(l)
return l
}()

private lazy var centralBlob: CALayer = {
let l = CALayer()
l.backgroundColor = UIColor.black.cgColor
layer.addSublayer(l)
return l
}()

override func layoutSubviews() {
super.layoutSubviews()

outerBand.frame = bounds
outerBand.lineWidth = thicknessOfOuterRing
outerBand.path = circlePathForCurrentLayout.cgPath

centralBlob.frame = bounds.inset(by:
UIEdgeInsets(top: both, left: both, bottom: both, right: both))
centralBlob.cornerRadius = centralBlob.bounds.width / 2.0
}
}

Next steps:

If you want to take on the challenge of having an image in the middle: link

Sample Image

If you want to, eek, take on shadows: link

Looking at gradients? link

Partial arcs: link

Add Shadow to CAShapeLayer with UIBezierPath

Output:

Sample Image

UIView Extension:

extension UIView {
func renderCircle() {
let semiCircleLayer = CAShapeLayer()

let center = CGPoint (x: self.frame.size.width / 2, y: self.frame.size.height / 2)
let circleRadius = self.frame.size.width / 2
let circlePath = UIBezierPath(arcCenter: center, radius: circleRadius, startAngle: CGFloat(Double.pi * 2), endAngle: CGFloat(Double.pi), clockwise: true)

semiCircleLayer.path = circlePath.cgPath
semiCircleLayer.strokeColor = UIColor.red.cgColor
semiCircleLayer.fillColor = UIColor.clear.cgColor
semiCircleLayer.lineWidth = 8
semiCircleLayer.shadowColor = UIColor.red.cgColor
semiCircleLayer.shadowRadius = 25.0
semiCircleLayer.shadowOpacity = 1.0
semiCircleLayer.shadowPath = circlePath.cgPath.copy(strokingWithWidth: 25, lineCap: .round, lineJoin: .miter, miterLimit: 0)

semiCircleLayer.shadowOffset = CGSize(width: 1.0, height: 1.0)
self.layer.addSublayer(semiCircleLayer)
}
}

Usage:

semiCircleView.renderCircle()


Related Topics



Leave a reply



Submit