Introduction
The Apple Watch shipped with a captivating activity tracker. The center piece is a really cool spiral animation scheme showing the amount of activity during the day. This image is also shown on the matching iPhone Activity App. I have always wanted to see what it would take to implement this myself. The examples that I see typically use a custom drawRect override, but I always wanted to see what it would take to do with CAShapeLayers.
Implementing a 0-100% control is straight forward when using CAShapeLayer. But how do you implement a progress indicator that support progress values greater then 100%? This How To discusses a solution that I came up with along with it’s potential limitations.
The source code for this how to is located on Github here: CustomProgressIndicator
The Demo Application
The demo application is a simple single view application. The applications main view contains three custom progress controls. Each control represents a individual circle. The buttons at the bottom of the view add or remove progress to the custom controls.
The Basic Solution
The solution that I came up with uses three custom controls, one for each circle. All three controls are played out on the same footprint, a square region centered horizontally in the main view. You can verify this using the storyboard editor.
As you will see the custom control has properties that control the color, placement, number of rotations, among others. The properties are set using the storyboard editor.
There is an issue with the storyboard editor. Apparently the editor does not support proper rotation/drop shadow orientation on the end caps (more on this later).
CAShapeLayer
Each custom control has a progress setting that allows you to control how much progress is shown. The arc is updated in a animated manor when the progress value changes. The
The arc is implemented by using three CAShapeLayers. 1. Start Cap Layer - This is a layer for the starting line cap. IT is used to display the starting point of the arc, even when the progress is zero. Nothing is done with this layer after it is created. 2. Arc Layer - This layer draws main body of the line and is updated when the progress property is changed. 3. End Cap Layer - This layer shows the end cap for the arc. It has a drop shadow to give the shadow on the line when the progress has rotated back onto itself.
The layers are added into the views layer by calling the private method setupShapeLayer in the views init methods. This routing performs the basic initialization of the three shape layers.
/// A utility used to setup the shapeLayers on initialization. Each layer /// is initialized with the correct properties and loaded into view's /// CALayer hierarchy. private func setupShapeLayers() { let frame = circleFrame() setupShapeLayer(startLayer, frame: frame) setupShapeLayer(arcLayer, frame: frame) arcLayer.lineWidth = lineWidth arcLayer.lineCap = kCALineCapRound setupShapeLayer(endLayer, frame: frame) endLayer.shadowColor = UIColor.blackColor().CGColor endLayer.shadowRadius = lineWidth endLayer.shadowOpacity = 0.6 endLayer.shadowOffset = CGSizeMake(lineWidth, 0) } /// A utility method used to perform redundant setup steps for a CAShapeLayer. /// This method also adds the shape layer to the views layer. /// /// - parameter shapeLayer: A CAShapeLayer to initialize and add into the /// view's CALayer hiearchy /// - parameter frame: A CGRect that is the new frame to use private func setupShapeLayer(shapeLayer: CAShapeLayer, frame: CGRect) { shapeLayer.fillColor = UIColor.clearColor().CGColor shapeLayer.strokeColor = color?.CGColor layer.addSublayer(shapeLayer) }
The frame is calculated using the private circleFrame method.
/// A utility method that returns the frame to use for the shape layers that /// do all the work. The frame is square with a width/height equal to /// one half the smallest dimension. /// /// - returns: A CGRect to use as the frame for a shape layer private func circleFrame() -> CGRect { let radius = min(bounds.width / 2, bounds.height / 2) let frame = CGRect(origin: CGPointMake(bounds.width / 2, bounds.height / 2), size: CGSizeZero) return CGRectInset(frame, -radius, -radius) }
The frame is calculated to be square centered in the view. Each layer’s frame value is set during the initialization process. Each layer’s frame is updated when the layoutSubviews method is called.
override func layoutSubviews() { super.layoutSubviews() let rect = circleFrame() reframeLayers(rect) }
The reframeLayers method recreates the CAShapeLayers path according to the new dimensions.
/// A utility called to update the CAShapeLayers when the view is laying /// out it's subviews. Each layers frame property is updated and then /// a new CGPath is loaded into the layer. /// /// parameter frame: A CGRect that is the new frame to use for the views private func reframeLayers(frame: CGRect) { reframeLayer(startLayer, frame: frame) startLayer.path = endCapPath().CGPath reframeLayer(arcLayer, frame: frame) arcLayer.path = circlePath().CGPath reframeLayer(endLayer, frame: frame) endLayer.path = endCapPath().CGPath }
The start and end layers each contain a CGPath that is just a circle. This path is generated calling the endCapPath method.
/// A method for generating a UIBezierPath for a end cap layer /// /// - returns: A UIBezierPath that is a end cap that uses the current /// radius, and lineWidth. private func endCapPath() -> UIBezierPath { let c = circleCenter() let capCenter = CGPointMake(c.x, c.y - circleRadius()) var rect = CGRect(origin: capCenter, size: CGSizeZero) rect = CGRectInset(rect, -lineWidth / 2, -lineWidth / 2) return UIBezierPath(ovalInRect: rect) }
The endCapPath method creates a UIBezierPath through its ovalInRect constructor. The circle is centered on the starting point for the arc. Which is at the 12 o’clock position. The radius of the circle is the same as the lineWidth for the arc path the we use for the arc layer.
/// A method for generating a UIBezierPath for the arc layer /// /// - returns: A UIBezierPath that is a arc that uses the current /// radius, and lineWidth. private func circlePath() -> UIBezierPath { let topAngle: CGFloat = CGFloat(-M_PI_2) let endAngle: CGFloat = CGFloat(2 * M_PI) * numberRotations - CGFloat(M_PI_2) let path = UIBezierPath(arcCenter: circleCenter(), radius: circleRadius(), startAngle: topAngle, endAngle: endAngle, clockwise: true) return path }
The CGPath for the arc layer is created using the circlePath method. This method returns a UIBezierPath. This path is created using the arcCenter:radius:startAngle:endAngle:clockwise constructor. The start and end angle are calculated such that they incorporate all the desired rotations.
Animations
The arc is updated by setting the progress property to a new value. The property has a didSet observer. This observer calls the updateLayerProgress method.
/// A private method that is called to perform the actual steps in updating /// the progress indicators. private func updateLayerProgress() { let fromStroke = arcLayer.strokeEnd let toStroke = progress / numberRotations let fromAngle = oldAngle // Calcuate the rotation angle... let delta: CGFloat = CGFloat(M_PI * 2.0) * progress - oldAngle let toAngle = oldAngle + delta let rotate = CATransform3DRotate(endLayer.transform, delta, 0, 0, 1) // Make the new setting together arcLayer.strokeEnd = toStroke endLayer.transform = rotate // Set the animation for the pending actions endLayer.addAnimation(endCapAnimation(fromAngle, toAngle: toAngle), forKey: "transform.rotation.z") arcLayer.addAnimation(circleAnimation(fromStroke, toStroke: toStroke, fromAngle: fromAngle, toAngle: toAngle), forKey: "strokeEnd") // Save the angle for the next time... oldAngle = toAngle }
This method updates the arc and end layers to the new value. It also creates an animation for each layer. So the first thing the method does is calculate the new value for the strokeEnd property on the arc layer. Following that, the method creates a new rotation transform for the end layer. The rotation transform supports multiple rotations.
The method sets the new strokeEnd value and adds the new transform to their respective layers. To support multiple rotations we need to create custom animations. If we do not do this, then the default animation is used that rotates directly to the new setting regardless of what the new setting are.
The endCapAnimation method is called for end layer.
/// A private method used to generate the animation for the end cap that rotates /// through the desired number of rotations. /// /// - parameter fromAngle: The starting angle in radians /// - parameter toAngle: The ending angle in radians. Can be more then 2*pie in change. /// - return: A CABasicAnimation properly setup for the desired changes. private func endCapAnimation(fromAngle: CGFloat, toAngle: CGFloat) -> CABasicAnimation { let ba = CABasicAnimation(keyPath: "transform.rotation.z") ba.fromValue = fromAngle ba.toValue = toAngle ba.duration = angleRotationDuration(fromAngle, toAngle: toAngle) ba.beginTime = CACurrentMediaTime() + animationDelay ba.cumulative = false ba.removedOnCompletion = true ba.fillMode = kCAFillModeBackwards ba.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) return ba }
While the circleAnimation method is called for the arc layer:
/// A private method used to generate the animation for the arc to show /// the main progress. The angle values are used to derive the correct /// duration and begin time of the animation. /// /// - parameter fromStroke: The current value of the strokeEnd CAShapeLayer property /// - parameter toStroke: What new new setting of the strokeEnd CAShapeLayer property /// - parameter fromAngle: The starting angle in radians /// - parameter toAngle: The ending angle in radians. Can be more then 2*pie in change. /// - return: A CABasicAnimation properly setup for the desired changes. private func circleAnimation(fromStroke: CGFloat, toStroke: CGFloat, fromAngle: CGFloat, toAngle: CGFloat) -> CABasicAnimation { let ba = CABasicAnimation(keyPath: "strokeEnd") ba.fromValue = fromStroke ba.toValue = toStroke ba.duration = angleRotationDuration(fromAngle, toAngle: toAngle) ba.beginTime = CACurrentMediaTime() + animationDelay ba.cumulative = false ba.removedOnCompletion = true ba.fillMode = kCAFillModeBackwards ba.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) return ba }
The angleRotation method is called in both cases to calculate the animation duration:
/// A private method used to calculate the proper duration of the end cap /// animation. /// /// - parameter fromAngle: The starting angle in radians /// - parameter toAngle: The ending angle in radians. Can be more then 2*pie in change. /// - return: The CFTimeInterval to be used for the animation private func angleRotationDuration(fromAngle: CGFloat, toAngle: CGFloat) -> CFTimeInterval { let deltaAngle = abs(toAngle - fromAngle) // Want to use a full rotation time for changes less then one rotation if deltaAngle < CGFloat(M_PI * 2) { return CFTimeInterval(rotationDuration) } else { let numberRotations = deltaAngle / CGFloat(2 * M_PI) let time = CGFloat(rotationDuration) * round(numberRotations) return CFTimeInterval(time) } }
It should be noted that the arc layer animates through multiple rotations even though you cannot tell. This allows us to use the same timing and timing function for both animations. In this case we are using the ease in/ease out timing function.
Limitations
I prefer this solution, but it has two limitations: 1. I have not shown the arrows that are part of the Apple activity indicator. I think this would be the next addition to the control. 2. Animation groups do not work across multiple CALayers. So it might be possible that the two animations will get out of sync. I have not run across a situation where this happens other then selecting a real fast animation duration.
Rodger Higgins is the founder of Spazstik Software, LLC. He has created StackCalc, The Visual Touch Calculator and SPZTracker.
Facebook Twitter Google+