Using UIBezierPath and CAShapeLayer to create animating views in iOS

Progress views, custom activity indicators, pie charts – everyone wants a circular view! But how do you make them? Squares are easy, and fortunately, circles are too, just with a little bit of extra work. Here I’ll show you how to create these two views – an animatable progress view and a custom activity indicator with two animation modes. Let’s begin!

 

 

 

We’re gonna be using the CAShapeLayer class to do the work for us here. CAShapeLayer has a property of ‘path’ which we’ll set to a circular UIBezierPath object which will give us a nice circle. Because drawing a circle seems useful to reuse, I’ll create an extension on CAShapeLayer that will draw me a circle with parameters I pass in. For this function I’m gonna pass in a CGRect which I’ll use to get the width/height and centre point for my circle. This is so I can use this function easily when overriding draw(_ rect: CGRect) on a UIView. The most complex part here is the UIBezierPath, so I’ll focus in on that.

First we need the centre of the rect, which is easy enough. Just grab the maxX and maxY and divide both by 2 and use that to construct a CGPoint object.

Screen Shot 2017-12-10 at 09.51.54

Now we need to get the radius for our circle. For testing you can set this to some arbitrary value like 300, but in practice you don’t want this, since your circle might be drawn outside the bounds of your view. If you think about it, you don’t want the size of your circle to be bigger than the smallest side of your view, whichever side that is. So if your view is 16:9 and landscape, for example, you don’t want your circle being drawn bigger than the height of your view. And if your view is 16:9 and portrait, you don’t want your circle drawn bigger than the width of your view. Basically, we need to figure out which side of our rect is smaller and use that to compute our radius. This is also super easy to do

Screen Shot 2017-12-10 at 09.56.25

Now we’re ready to draw our UIBezierPath! A thing to note here is the coordinate system used. For most things in iOS, the coordinate system you draw in starts at 0,0 in the top left of your view’s bounds. When you draw a circle, 0 degrees is actually 90 degrees clockwise from where you’d usually expect it to be.

coordinate_system

Note also that startAngle and endAngle take in radians. This is simple enough if you’re drawing a complete circle as shown below, but gets a bit tricky when you want to create a partially open circle. To get around this, you can include this CGFloat extension and then just call .toRadians on a CGFloat:

Screen Shot 2017-12-23 at 15.38.53

We’ll see this in use later. For now we’re just gonna use pi to create the radian for start and end angles.

Screen Shot 2017-12-10 at 09.57.34

The rest of this is easy. self.path = circularPath.cgPath will assign the UIBezierPath to the path property of your CAShapeLayer. Nothing will yet be drawn to the screen since we haven’t specified a colour for the fill or stroke. You can specify lineWidth, fillColor, strokeColor, lineCap, etc. All these can be passed in to the function to give you a nice API. One thing I like is when a function has more than 5 or so parameters, construct a configuration object and pass that in instead. So create a struct and enum like so:

Screen Shot 2017-12-10 at 10.01.16

And now modify our extension to use this PathConfiguration object we pass in:


extension CAShapeLayer {
func drawCircle(in rect: CGRect, with configuration: PathConfiguration) {
let center = CGPoint(x: rect.maxX / 2, y: rect.maxY / 2)
let longestSide = rect.height < rect.width ? rect.height : rect.width
let circularPath = UIBezierPath(arcCenter: center, radius: (longestSide / 2) (configuration.lineWidth / 2), startAngle: configuration.startAngle, endAngle: 2 * CGFloat.pi, clockwise: true)
self.path = circularPath.cgPath
self.fillColor = UIColor.clear.cgColor
self.strokeColor = configuration.color.cgColor
self.lineWidth = configuration.lineWidth
self.lineCap = kCALineCapRound
switch configuration.type {
case .progress:
self.strokeEnd = 0
case .track:
self.strokeEnd = 1
case .custom(let value):
self.strokeEnd = value
}
}
}
struct PathConfiguration {
let color: UIColor
let lineWidth: CGFloat
let startAngle: CGFloat
let type: TrackType
}
enum TrackType {
case progress, track
case custom(CGFloat)
}

strokeEnd is set to 0 if configuration.type == .progress because we want to animate the drawing of strokeEnd later. So that’s the basis of our circle views. Now to draw. Create a new Swift class that extends UIView and override draw(_ rect: CGRect). Now we can draw a circle in just four lines – create a CAShapeLayer object, create a PathConfiguration object, call CAShapeLayer#drawCircle and then add it to your view’s layer.

Screen Shot 2017-12-10 at 10.08.15

Now just create another CAShapeLayer called progressLayer and in its PathConfiguration object, set its type to .progress. Draw it like the track layer. Now we just need to create a function which will animate the strokeEnd property of our progress layer to a value. We can use a CABasicAnimation for this, providing a keyPath of “strokeEnd”.

Screen Shot 2017-12-10 at 10.13.53.png

progressLayer here is our CAShapeLayer for the progress which needs to be set as a property on our class. animation.fromValue just makes sure the oldValue isn’t smaller than 0 and if it is, makes it 0. animation.toValue does the same to make sure newValue isn’t bigger than 100. We divide both by 0 because fromValue and toValue take a number from 0 to 1, but we want our API to take a value from 0 to 100. Now we just need to call this function. Create a public Double property called progress, and call updateProgress in the didSet like this:

Screen Shot 2017-12-10 at 10.16.38

And now you’re ready to go! In IB, drag a UIView object out and set its class to the UIView subclass you created. Create an IBOutlet to it and then change the .progress property to whatever you want. Build and run and you’ll see the progress animate to the value you specified!

Just quickly back to the radians thing I talked about before. Let’s say you want to create a shape like this:

Screen Shot 2017-12-23 at 15.44.44

You can use the toRadians extension provided earlier to make this more readable. Now your UIBezierPath would just look like this. Remember we’re using 135 and 45 degrees because of the coordinate system iOS uses:

Screen Shot 2017-12-23 at 15.43.57

You can use the same ideas to create a custom activity indicator which I’ve done, but I think this is already pretty long, so instead I’ll just include the code below. It uses the same ideas as we discussed here just in a slightly different way.

And that’s how you create circular views in iOS. See you next time!

https://github.com/Stickerbox/CustomCircleViews

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s