Kodeco Forums

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.


This is a companion discussion topic for the original entry at https://www.raywenderlich.com/449-how-to-implement-a-circular-image-loader-animation-with-cashapelayer
1 Like

Great tutorial! However, I’m unable to make the starter project run in the simulator with Xcode Version 9.0 beta 6 (9M214v). The status bar says “Waiting to attach ImageLoaderIndicator on iPhone 7” but the app is never launched. I have no problem with other projects. Restarting Xcode and cleaning the project didn’t help. Anyone else experiencing the problem?

@mkatz Can you please help with this when you get a chance? Thank you - much appreciated! :]

@mkatz Hello Michael thank you for the great tutorial, I have one question. I have finished the tutorial but for a split second after the expanding rings animation I can see the white circle flash and then disappear, however it is smooth in your gif. Could you please help me get rid of the animation glitch?

I just did the tutorial on Xcode 9 beta 6 and it worked fine, did you check the developer profile? maybe restart your laptop? the betas are still quite buggy…

thanks, I re-downloaded the starter project and now it’s working.

I’ll post an update once we get the GM.

hi,
I could not solve the flash issue
how did you solve it
there is a flash at the end of animation on my iphone 6s
it does not happen on simulator
thanks

@mkatz Do you have any feedback regarding this? Thank you - much appreciated! :]

What version of XCode and iOS are you using?

hi,
the latest version xcode 9.1 and ios 11.1

hi,
unfortunately the circular path does not show up i just get the effect with an extra flash in the end of image loading
below is my code:

and here is how i use it

let progressIndicatorView = CircularLoaderView(frame: .zero)
func returnImageWithprogress(imageView:UIImageView,url:String){

    imageView.addSubview(progressIndicatorView)
    
    imageView.addConstraints(NSLayoutConstraint.constraints(
        withVisualFormat: "V:|[v]|", options: .init(rawValue: 0),
        metrics: nil, views: ["v": progressIndicatorView]))
    imageView.addConstraints(NSLayoutConstraint.constraints(
        withVisualFormat: "H:|[v]|", options: .init(rawValue: 0),
        metrics: nil, views:  ["v": progressIndicatorView]))
    progressIndicatorView.translatesAutoresizingMaskIntoConstraints = false
    
    let url = URL(string: url)
    
    imageView.sd_setImage(with: url, placeholderImage: nil, options: .progressiveDownload, progress:
        { [weak self] receivedSize, expectedSize, _ in
            self?.progressIndicatorView.progress = CGFloat(receivedSize) / CGFloat(expectedSize)
    }) { [weak self] _, error, _, _ in
        if let error = error {
            print(error)
        }
        self?.progressIndicatorView.reveal()
    }
}

import UIKit

class CircularLoaderView: UIView {
let circlePathLayer = CAShapeLayer()
let circleRadius: CGFloat = 20.0

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
}
}
}

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
progress = 0
}

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
}

func circlePath() → UIBezierPath {
return UIBezierPath(ovalIn: circleFrame())
}

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

func reveal() {
// 1
backgroundColor = .clear
progress = 1
// 2
circlePathLayer.removeAnimation(forKey: “strokeEnd”)
// 3
circlePathLayer.removeFromSuperlayer()
superview?.layer.mask = circlePathLayer

// 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]

groupAnimation.delegate = self

circlePathLayer.add(groupAnimation, forKey: "strokeWidth")

}
}

extension CircularLoaderView: CAAnimationDelegate {
func animationDidStop(_ anim: CAAnimation, finished flag: Bool) {
superview?.layer.mask = nil
}
}

This tutorial is more than six months old so questions are no longer supported at the moment for it. Thank you!