My book on iOS interface design, Design Teardowns: Step-by-step iOS interface design walkthroughs is now available!

Design Teardown: Spinning Indicator

Having worked on a Material Design themed project lately, I wanted to create the loading spinner seen now in many Google apps. Here's how I experimented to create the following effect.

Final Animation

Starting with a new project, add a UIView to the storyboard and added subclass called SpinningView. Next, we need to show a circle. I'll opt to use a CAShapeLayer as that gives us more flexibility with animation.

class SpinningView: UIView {

    let circleLayer = CAShapeLayer()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }

    override func awakeFromNib() {
        super.awakeFromNib()
        setup()
    }
}

Most of the above code is boilerplate. We create a constant property, circleLayer and initialize it right away so we can forget about working with Optionals. Next we need to find a hook to add it to the view/layer hierarchy. For views instantiated programmatically, this is clearly init(frame:). But if we happen to add this in the storyboard and set the Custom Class in Interface Builder, this won't work. So another option is to use awakeFromNib().

func setup() {  
    circleLayer.lineWidth = 4
    circleLayer.fillColor = nil
    circleLayer.strokeColor = UIColor(red: 0.8078, green: 0.2549, blue: 0.2392, alpha: 1.0).CGColor
    layer.addSublayer(circleLayer)
}

I've refactored the setup into a method called (you guessed it) setup() and added the call to it in both methods.

override func layoutSubviews() {  
    super.layoutSubviews()

    let center = CGPoint(x: bounds.midX, y: bounds.midY)
    let radius = min(bounds.width, bounds.height) / 2 - circleLayer.lineWidth/2

    let startAngle = CGFloat(-M_PI_2)
    let endAngle = startAngle + CGFloat(M_PI * 2)
    let path = UIBezierPath(arcCenter: CGPointZero, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true)

    circleLayer.position = center
    circleLayer.path = path.CGPath
}

Next, we configure some layer properties to show the circle. Because we're drawing the circle using a path, we can simply set lineWidth to however thick we want the line to be. We also clear out the fillColor and set the strokeColor. Note the two properties take a CGColor not UIColor.

All we need to do now is configure the path object itself. We want to the circle to be as big as the view so it will draw itself at the right size when the view is resized. We do this in layoutSubviews(). We calculate the center position halfway between the width and height of the bounds. Because we want the circle to fit within the view, the radius can't be bigger than the view's width or height. We take the smaller of them and halve it. Finally we need to subtract half of the lineWidth because the stroke draws outward from the center of the path.

To make things simple later on, let's have the path draw from the top-most position and run clockwise. If you remember enough geometry, angles are measured from the positive x-axis and go clockwise. So we start at -M_PI_2 or -90 degrees.

Build and run and you should see a red circle:

Circle

Let's take advantage of some of the new features and make debugging our custom view a bit more manageable. Let's have the view display in Interface Builder:

@IBDesignable
class SpinningView: UIView {  
    ...

    override func prepareForInterfaceBuilder() {
        super.prepareForInterfaceBuilder()
        setup()
    }
}

By providing the @IBDesignable attribute and the prepareForInterfaceBuilder() method, we tell Xcode to draw our view's custom appearance in Interface Builder. You can test this out by going to the storyboard.

Next we can adopt the tintColor attribute by implementing the following:

func setup() {  
    ...
    tintColorDidChange()
}

override func tintColorDidChange() {  
    super.tintColorDidChange()
    circleLayer.strokeColor = tintColor.CGColor
}

Go into the storyboard and see the circle change color. Change the Tint in the Attributes Inspector and notice how the color changes.

We'll expose one more property to control the lineWidth:

@IBInspectable var lineWidth: CGFloat = 4 {
    didSet {
        circleLayer.lineWidth = lineWidth  
        setNeedsLayout()        
    }
}

func setup() {  
    circleLayer.lineWidth = lineWidth
    ...
}

Have a go at changing the property in the Attributes Inspector. The @IBDesignable and @IBInspectable attributes make it really easy to debug custom views!

Alright, let's get into the meat of the experiment and have a try at animating the circle. The animation can be broken down into a few parts.

  • The stroke is animated from the beginning of the path to the end of the path. This animation can be broken up further.
    • The end position of the stroke is animated from the beginning of the path to the end of the path.
    • The start position of the stroke is animated from the beginning of the path to the end of the path.
  • The circle itself is put into rotation.

Let's start with the strokeEnd animation:

let strokeEndAnimation: CAAnimation = {  
    let animation = CABasicAnimation(keyPath: "strokeEnd")
    animation.fromValue = 0
    animation.toValue = 1
    animation.duration = 2
    animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
    animation.repeatCount = MAXFLOAT
    return animation
    }()

The strokeEnd and strokeStart properties take a value from 0 (beginning of the path) to 1 (end of the path). We're also using a timing function of ease-in-ease-out to make the animation a bit more natural-looking. Finally, the repeatCount is the number of times the animation will repeat. We'll just set this to a large number.

Next we'll add a property to control this growing animation:

@IBInspectable var animating: Bool = true {
    didSet {
        updateAnimation()
    }
}

func setup() {  
    ...

    tintColorDidChange()
    updateAnimation()
}

func updateAnimation() {  
    if animating {
        circleLayer.addAnimation(strokeEndAnimation, forKey: "strokeEnd")
    }
    else {
        circleLayer.removeAnimationForKey("strokeEnd")
    }
}

Build and run and you should see the following:

Animation with strokeEnd

Next, we'll add animation for strokeStart so the stroke shrinks at the other end as it grows clockwise. However, we need to sort of delay the shrinking so it won't shrink at the same rate it grows. We need to create an animation group to add this delay.

let strokeEndAnimation: CAAnimation = {  
    let animation = CABasicAnimation(keyPath: "strokeEnd")
    animation.fromValue = 0
    animation.toValue = 1
    animation.duration = 2
    animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)

    let group = CAAnimationGroup()
    group.duration = 2.5
    group.repeatCount = MAXFLOAT
    group.animations = [animation]

    return group
    }()

let strokeStartAnimation: CAAnimation = {  
    let animation = CABasicAnimation(keyPath: "strokeStart")
    animation.beginTime = 0.5
    animation.fromValue = 0
    animation.toValue = 1
    animation.duration = 2
    animation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)

    let group = CAAnimationGroup()
    group.duration = 2.5
    group.repeatCount = MAXFLOAT
    group.animations = [animation]

    return group
    }()

func updateAnimation() {  
    if animating {
        circleLayer.addAnimation(strokeEndAnimation, forKey: "strokeEnd")
        circleLayer.addAnimation(strokeStartAnimation, forKey: "strokeStart")
    }
    else {
        circleLayer.removeAnimationForKey("strokeEnd")
        circleLayer.removeAnimationForKey("strokeStart")
    }
}    

Build and run and you should see the following:

Animation with strokeStart and strokeEnd

Finally, we'll add the rotation for a last bit of polish.

let rotationAnimation: CAAnimation = {  
      let animation = CABasicAnimation(keyPath: "transform.rotation.z")
      animation.fromValue = 0
      animation.toValue = M_PI * 2
      animation.duration = 4
      animation.repeatCount = MAXFLOAT
      return animation
      }()

func updateAnimation() {  
    if animating {
        circleLayer.addAnimation(strokeEndAnimation, forKey: "strokeEnd")
        circleLayer.addAnimation(strokeStartAnimation, forKey: "strokeStart")
        circleLayer.addAnimation(rotationAnimation, forKey: "rotation")
    }
    else {
        circleLayer.removeAnimationForKey("strokeEnd")
        circleLayer.removeAnimationForKey("strokeStart")
        circleLayer.removeAnimationForKey("rotation")
    }
}

Final Animation

And there we have it - a Material Design-styled loading spinner!