How To: Custom iOS Activity Tracker View Using CALayers

Date Published:
Last Modified: by

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.

Recent Articles

A Reusable Observer Protocol Written In Swift

One design pattern that I use a lot is the observer pattern. The observer pattern is used when you have an object that needs to notify a list of objects that state changes have happened. This article discusses a reusable component, in Swift, I developed to speed up my development process.


Read More...
How To: Support User Editable Python Macros In A I Os Application

Last month I published a article on how to use JavascriptCore for extending a iOS application with macro support. While Javascript has many uses, as a way for application customization, it would not be my first choice.

A better choice to me would be a language like Python. Being curious, I wondered what it would take to to use Python. This article discusses what I found.


Read More...
How To Example: Extend A I Os Using Javascript Core As A Macro Engine

JavascriptCore is a framework that offers the ability for a iOS application to interact with javascript code. Primarily used for cross platform code sharing, it can also be used to extend a iOS application with macro capabilities. This framework offers a world of possibilities for extending any iOS application.


Read More...
How To: Custom I Os Activity Tracker View Using Ca Layers

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.


Read More...
How To Display Custom Content On A External Screen From A I Os Device

Being able to display content on a external screen or device is a great capability to add to a iOS application. Especially how easy it is. This article will show the step required to to do this.


Read More...

Follow us on

Articles by published month

Articles by subject matter

Rails Thor Compass Susy Modernizr Rspec Capybara Bettererrors Railspanel Aws Rack Railscasts Http Aws-elastic-beanstalk Ruby-on-rails Rack-rewrite Http-response-codes Pow-amazon-route-53 Stackcalc Iphone Ios Mobile Application Skeumorphic Dns Web-site Elastic-beanstalk Elastic-ip Elastic-load-balancer Tutorial Howto Javascript Javascriptcore Macro Example Swift Design-patterns Observer Python Macros Alamofire External-screen-support Apple-watch Activity-tracker Office Status S100 Cloud Astronomy Picture Mars Apple Usb-c Leonard-nimoy William-shatner Geotag Gps Spztracker Geotagging Photos Secret Marketing Watch Watch-repair Head-transplants Perception Diabeties Sugar Health Rosette-nebula News Spock Comet-lovejoy

Click here to receive free tips and tutorials!

This web site uses javascript exclusively for automating html elements. Please enable javascript to fully experience the features offered on this site.