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.
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:
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:
- Circular progress. First, you’ll draw a circular progress indicator and update it based on the progress of the download.
- 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.
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:
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:
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.
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
.
Build and run your project. You'll see the progress indicator begin to move like so:
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! :]
Creating the Reveal Animation
The reveal phase gradually displays the image in a window in the shape of an expanding circular ring. If you’ve read this tutorial on creating a Ping-style view controller animation, you'll know this is a perfect use-case of the mask
property of a CALayer
.
Open CircularLoaderView.swift and add the following method:
func reveal() {
// 1
backgroundColor = .clear
progress = 1
// 2
circlePathLayer.removeAnimation(forKey: "strokeEnd")
// 3
circlePathLayer.removeFromSuperlayer()
superview?.layer.mask = circlePathLayer
}
This is an important method to understand, so let's go over this section by section:
- You clear the view’s background color so the image behind the view isn’t hidden anymore, and you set
progress
to 1. - You remove any pending implicit animations for the
strokeEnd
property, which may have otherwise interfered with the reveal animation. For more about implicit animations, check out iOS Animations by Tutorials. - You remove
circlePathLayer
from itssuperLayer
and assign it instead to thesuperView
’s layer mask, so the image is visible through the circular mask "hole". This lets you reuse the existing layer and avoid duplicating code.
Now you need to call reveal()
from somewhere. Replace the Reveal image here
comment in CustomImageView.swift with the following:
if let error = error {
print(error)
}
self?.progressIndicatorView.reveal()
Build and run. Once the image downloads you'll see it partially revealed through a small ring:
You can see your image in the background — but just barely! :]
Expanding Rings
Your next step is to expand this ring both inwards and outwards. You could do this with two separate, concentric UIBezierPath
, but you can do it in a more efficient manner with just a single Bezier path.
How? You simply increase the circle’s radius to expand outward by changing the path
property, while simultaneously increasing the line's width to make the ring thicker and expand inward by changing the lineWidth
property. Eventually, both values grow enough to reveal the entire image underneath.
Open CircularLoaderView.swift and add the following code to the end of reveal()
:
// 1
let center = CGPoint(x: bounds.midX, y: bounds.midY)
let finalRadius = sqrt((center.x*center.x) + (center.y*center.y))
let radiusInset = finalRadius - circleRadius
let outerRect = circleFrame().insetBy(dx: -radiusInset, dy: -radiusInset)
let toPath = UIBezierPath(ovalIn: outerRect).cgPath
// 2
let fromPath = circlePathLayer.path
let fromLineWidth = circlePathLayer.lineWidth
// 3
CATransaction.begin()
CATransaction.setValue(kCFBooleanTrue, forKey: kCATransactionDisableActions)
circlePathLayer.lineWidth = 2*finalRadius
circlePathLayer.path = toPath
CATransaction.commit()
// 4
let lineWidthAnimation = CABasicAnimation(keyPath: "lineWidth")
lineWidthAnimation.fromValue = fromLineWidth
lineWidthAnimation.toValue = 2*finalRadius
let pathAnimation = CABasicAnimation(keyPath: "path")
pathAnimation.fromValue = fromPath
pathAnimation.toValue = toPath
// 5
let groupAnimation = CAAnimationGroup()
groupAnimation.duration = 1
groupAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
groupAnimation.animations = [pathAnimation, lineWidthAnimation]
circlePathLayer.add(groupAnimation, forKey: "strokeWidth")
This might look like a lot of code, but what you're doing here is fairly simple:
- You determine the radius of the circle that can fully circumscribe the image view and use it to calculate the
CGRect
that would fully bound this circle.toPath
represents the final shape of theCAShapeLayer
mask like so:
- You set the initial values of
lineWidth
andpath
to match the current values of the layer. - You set
lineWidth
andpath
to their final values. This prevents them from jumping back to their original values when the animation completes. By wrapping this changes in aCATransaction
withkCATransactionDisableActions
set totrue
you disable the layer’s implicit animations. - You create two instances of
CABasicAnimation
: one forpath
and the other forlineWidth
.lineWidth
has to increase twice as fast as the radius increases in order for the circle to expand inward as well as outward. - You add both animations to a
CAAnimationGroup
, and add the animation group to the layer.
Build and run your project. You’ll see the reveal animation kick-off once the image finishes downloading:
Notice a portion of the circle remains on the screen once the reveal animation is done. To fix this, add the following extension to the end of CircularLoaderView.swift implementing animationDidStop(_:finished:)
:
extension CircularLoaderView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
superview?.layer.mask = nil
}
}
This code removes the mask on the super layer, which removes the circle entirely.
Finally, at the bottom of reveal()
, just above the line circlePathLayer.add(groupAnimation, forKey: "strokeWidth")
add the following line:
groupAnimation.delegate = self
This assigns the delegate so the animationDidStop(_:finished:)
gets called.
Build and run your project. Now you’ll see the full effect of your animation:
Congratulations, you've finished creating the circular image loading animation!
Where to Go From Here?
You can download the completed project here.
From here, you can further tweak the timing, curves and colors of the animation to suit your needs and personal design aesthetic. One possible improvement is to use kCALineCapRound
for the shape layer's lineCap
property to round off the ends of the circular progress indicator. See what improvements you can come up with on your own!
If you enjoyed this CAShapeLayer
tutorial and would like to learn how to create more animations like these, check out Marin Todorov's book iOS Animations by Tutorials, which starts with basic view animations and moves all the way to layer animations, animating constraints, view controller transitions, and more.
If you have any questions or comments about the CAShapeLayer
tutorial, please join the discussion below. I'd also love to see ways in which you've incorporated this cool animation in your app!
The post How To Implement A Circular Image Loader Animation with CAShapeLayer appeared first on Ray Wenderlich.