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.
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
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.
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:
We’ll see this in use later. For now we’re just gonna use pi to create the radian for start and end angles.
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:
And now modify our extension to use this PathConfiguration object we pass in:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
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”.
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:
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:
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:
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!