How To Implement A Circular Image Loader Animation with CAShapeLayer

Learn how to add an eye-catching circular loading animation to your iOS apps using CAShapeLayer and Swift in this tutorial. By Michael Katz.

Leave a rating/review
Save for later
Share

Update note:: This tutorial has been updated for Xcode 9, iOS 11 and Swift 4 by Michael Katz. The original tutorial was written by Rounak Jain.

CAShapeLayer tutorial

A long while ago, Michael Villar created a really interesting loading animation for his post on Motion Experiments.

The GIF to the right shows the loading animation, which marries a circular progress indicator with a circular reveal animation. The combined effect is fascinating, unique, and more than a little mesmerizing! :]

This CAShapeLayer tutorial will show you how to recreate this exact effect in Swift and Core Animation. Let’s get animating!

Getting Started

First download the starter project for this CAShapeLayer tutorial.

Take a minute and browse through the project once you’ve extracted it. There’s a ViewController that has a UIImageView subclass named CustomImageView, along with a SDWebImage method call to load the image. The starter project already has the views and image loading logic in place.

Build and run. After a moment, you should see a simple image displayed as follows:

CAShapeLayer tutorial

You might notice when you first run the app, the app seems to pause for a few seconds while the image downloads, then the image appears on the screen without fanfare. Of course, there’s no circular progress indicator at the moment – that’s what you’ll create in this CAShapeLayer tutorial!

You’ll create this animation in two distinct phases:

  1. Circular progress. First, you’ll draw a circular progress indicator and update it based on the progress of the download.
  2. Expanding circular image. Second, you’ll reveal the downloaded image through an expanding circular window.

Follow along closely to prevent yourself from going “round in circles”! :]

Creating the Circular Indicator

Think for a moment about the basic design of the progress indicator. The indicator is initially empty to show a progress of 0%, then gradually fills in as the image is downloaded. This is fairly simple to achieve with a CAShapeLayer whose path is a circle.

Note: If you’re new to the concept of CAShapeLayer (or CALayers in general, check out Scott Gardner’s CALayer in iOS with Swift article.

You can control the start and end position of the outline, or stroke, of your shape with the CAShapeLayer properties strokeStart and strokeEnd. By varying strokeEnd between 0 and 1, you can fill in the stroke appropriately to show the progress of the download.

Let’s try this out. Create a new file with the iOS\Source\Cocoa Touch Class template. Name it CircularLoaderView and set subclass of to UIView as shown below:

CAShapeLayer tutorial

Click Next, and then Create. This new subclass of UIView will house all of your new animation code.

Open CircularLoaderView.swift and add the following properties to the top of the class:

let circlePathLayer = CAShapeLayer()
let circleRadius: CGFloat = 20.0

circlePathLayer represents the circular path, while circleRadius will be the radius of the circular path. Rocket science! I know.

Next, add the following initialization code right below circleRadius to configure the shape layer:

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

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

func configure() {
  circlePathLayer.frame = bounds
  circlePathLayer.lineWidth = 2
  circlePathLayer.fillColor = UIColor.clear.cgColor
  circlePathLayer.strokeColor = UIColor.red.cgColor
  layer.addSublayer(circlePathLayer)
  backgroundColor = .white
}

Both of the initializers call configure(). configure() sets up circlePathLayer to have a frame that matches the view’s bounds, a line width of 2 points, a clear fill color and a red stroke color. Next, it adds the shape layer as a sublayer of the view’s own layer and sets the view’s backgroundColor to white so the rest of the screen is blanked out while the image loads.

Adding the Path

Now you’ve configured the layer, it’s time to set its path. Start by adding the following helper method right below configure():

func circleFrame() -> CGRect {
  var circleFrame = CGRect(x: 0, y: 0, width: 2 * circleRadius, height: 2 * circleRadius)
  let circlePathBounds = circlePathLayer.bounds
  circleFrame.origin.x = circlePathBounds.midX - circleFrame.midX
  circleFrame.origin.y = circlePathBounds.midY - circleFrame.midY
  return circleFrame
}

In this simple method you calculate the CGRect to contain the indicator’s path. You set the bounding rectangle to have a width and a height equals to 2 * circleRadius and position it at the center of the view. The reason why you wrote a separate method to handle this simple operation is you’ll need to recalculate circleFrame each time the view’s size changes.

Next, add the following method below circleFrame() to create your path:

func circlePath() -> UIBezierPath {
  return UIBezierPath(ovalIn: circleFrame())
}

This simply returns the circular UIBezierPath as bounded by circleFrame(). Since circleFrame() returns a square, the “oval” in this case will end up as a circle.

Since layers don’t have an autoresizingMask property, you’ll override layoutSubviews to respond appropriately to changes in the view’s size.

Override layoutSubviews() by adding the following code:

override func layoutSubviews() {
  super.layoutSubviews()
  circlePathLayer.frame = bounds
  circlePathLayer.path = circlePath().cgPath
}

You’re calling circlePath() here because a change in the frame should also trigger a recalculation of the path.

Open CustomImageView.swift. Add the following property to the top of the class:

let progressIndicatorView = CircularLoaderView(frame: .zero)

This property is an instance of the CircularLoaderView class you just created.

Next, add the following to init(coder:), right before let url...:

addSubview(progressIndicatorView)

addConstraints(NSLayoutConstraint.constraints(
  withVisualFormat: "V:|[v]|", options: .init(rawValue: 0),
  metrics: nil, views: ["v": progressIndicatorView]))
addConstraints(NSLayoutConstraint.constraints(
  withVisualFormat: "H:|[v]|", options: .init(rawValue: 0),
  metrics: nil, views:  ["v": progressIndicatorView]))
progressIndicatorView.translatesAutoresizingMaskIntoConstraints = false

Here you add the progress indicator view as a subview of the custom image view. Then you add two layout constraints to ensure the progress indicator view remains the same size as the image view. Finally, you set translatesAutoresizingMaskIntoConstraints to false so the autoresizing mask doesn’t interfere with the Auto Layout engine.

Build and run your project; you should see a red, hollow circle appear like so:

CAShapeLayer tutorial

Awesome! Your progress indicator is showing on the screen.

Modifying the Stroke Length

Open CircularLoaderView.swift and add the following lines directly below the other properties in the file:

var progress: CGFloat {
  get {
    return circlePathLayer.strokeEnd
  }
  set {
    if newValue > 1 {
      circlePathLayer.strokeEnd = 1
    } else if newValue < 0 {
      circlePathLayer.strokeEnd = 0
    } else {
      circlePathLayer.strokeEnd = newValue
    }
  }
}

Here you create a computed property — that is, a property without any backing variable — that has a custom setter and getter. The getter simply returns circlePathLayer.strokeEnd, and the setter validates the input is between 0 and 1 and sets the layer’s strokeEnd property accordingly.

Add the following line at the top of configure() to initialize progress on first run:

progress = 0

Build and run your project; you should see nothing but a blank white screen. Trust me! This is good news! :] Setting progress to 0 in turn sets the strokeEnd to 0, which means no part of the shape layer was drawn.

CAShapeLayer tutorial

The only thing left to do with your indicator is to update progress in the image download callback.

Open CustomImageView.swift and replace the comment Update progress here with the following:

self?.progressIndicatorView.progress = CGFloat(receivedSize) / CGFloat(expectedSize)

Here you calculate the progress by dividing receivedSize by expectedSize.

Note: You'll notice the block uses a weak reference to self - this is to avoid a retain cycle.

Build and run your project. You'll see the progress indicator begin to move like so:

CAShapeLayer tutorial

Even though you didn't add any animation code yourself, CALayer handily detects any animatable property on the layer and smoothly animates it as it changes. Neat!

That takes care of the first phase. Now on to the second and final phase — the big reveal! :]