A pet project of mine requires replicating one of the progress indicators in the Health app. I want to be able to configure the ring’s thickness, colour, and percentage of completion.
Before launching Xcode and start writing code right away, I wrote down what I knew about the problem I wanted to solve:
- CAShapeLayer seems to be a good fit for this.
- Animatable properties are, well, animatable. And for free. All you need to do is set their new value, and core animation will take care of animating the transition for you.
- It is easy to build an oval UIBezierPath.
- It is easy to apply transformations to bezier paths
- It should be possible to configure the view both programatically and in Interface Builder.
With that in mind, I decided to build a subclass of UIView, insert a CAShapeLayer into it, and bind the “progress” property of my view to the layer’s strokeEnd property. Also, I decided to took this as an opportunity to explore @IBDesignable and @IBInspectable, which is something I’d never done before.
And this is the end result:
The source code is available on GitHub, but let’s take a look at some of the highlights.
Display a custom view in Interface Builder
This one was easier than I expected. I can not claim to be an expert in Interface Builder, so I expected a lot of friction here, but annotating the class as IBInspectable was enough to make Interface Builder aware of it:
@IBDesignable public class Ring: UIView {
...
}
Making the view configurable through interface Builder (as well as programatically) was also quite easy, just by annotating properties as IBInspectable and providing a default value for them:
@IBInspectable public var progress: CGFloat = Constants.defaultProgress {
didSet {
progress = normalize(progress)
pathLayer.strokeEnd = progress
}
}
@IBInspectable public var lineWidth: CGFloat = Constants.defaultLineWidth {
didSet {
pathLayer.lineWidth = lineWidth
}
}
Building the actual ring
There might be a better way to do this -I’m looking at you, UIBezierPath(arcCenter, radius, startAngle, endAngle,clockwise)- but I decided to rely on the fact that UIBezierPath’s strokeEnd is animatable (which means that everytime I cahnge its value, CoreAnimation will animate the changes for me), so I built the path as an oval, and set the strokeEnd to the value of the progress property (which is normalized between 0 and 1).
Of course, that makes the path start at the X axis, so the path’s layer needs to be rotated -PI/2.
After that, it is just a matter of setting the proper frame, and path in the layoutSubviews method. Here is the relevant code:
override init(frame: CGRect) {
super.init(frame: frame)
configureRing()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
configureRing()
}
private func configureRing() {
pathLayer.frame = bounds
pathLayer.lineWidth = lineWidth
pathLayer.fillColor = fillColor.CGColor
pathLayer.strokeColor = lineColor.CGColor
layer.addSublayer(pathLayer)
pathLayer.anchorPoint = CGPointMake(0.5, 0.5)
pathLayer.transform = CATransform3DRotate(pathLayer.transform, -0.5 * CGFloat(M_PI), 0.0, 0.0, 1.0);
}
override public func layoutSubviews() {
super.layoutSubviews()
pathLayer.frame = bounds
pathLayer.path = ringPath().CGPath
}
private func ringPath() -> UIBezierPath {
return UIBezierPath(ovalInRect: ringFrame())
}
private func ringFrame() -> CGRect {
return CGRectInset(CGRect(x: 0, y: 0, width: CGRectGetHeight(bounds), height: CGRectGetHeight(bounds)), lineWidth*2, lineWidth*2)
}
The full source is available on GitHub, and can be pulled in as a dependency through Carthage.
One Reply to “Building a custom ring”