Swift: Springboard-like loading animation using a custom layer

One thing I have struggled to wrap my head around in iOS is what’s the concept behind some of the eye candy that happens on screen. Being a long time full stack web developer, the concept of using graphic contexts to draw into is not obscure, since that’s what the <canvas> drawing/animation is based on, but with iOS there’s more than this.

You have the CoreGraphics stack, but you also have some UIKit tools that allow you to draw using simplified syntax (UIBezierPath being the most colorful example). In today’s post I’ll focus on using CoreGraphics instead of UIBezierPath, although my solution started with using UIBezierPath. In short, it went nowhere, so I had to go back to CoreGraphics 🙂

I always believed that, to learn something, one should have a real problem to solve. My plan was to mimic what happens on the iOS springboard when an app installs – there’s a layer that dims the app icon and a circular pie slice animation that shows the application download progress, knocking out more and more of the dimming as the progress advances.

It’s a good idea to toy with so here’s what I ended up with:

loader2

First, my StackOverflow research revealed that simply creating a layer to host the graphic won’t be enough and I had to create a custom subclass off of the CALayer. Then I had to override one of its static methods (needsDisplayForKey) to allow the endAngle property of my class to become animated.

Not doing it simply doesn’t track the animation intermediate states, therefore resulting in pie slices “skipping” between the values. What makes it better is that XCode 6.1.1 doesn’t display that method in its autocomplete menu. Lesson learned here – read the documentation carefully.

Also notice how the endAngle variable is declared with @NSManaged, since this is the property we’re gonna animate on:

@NSManaged var endAngle: CGFloat
var startAngle = CGFloat(-0.5 * M_PI)
var maxAngle = CGFloat(1.5 * M_PI)

private var circleOffset:CGFloat = 30.0

var center: CGPoint {
  return CGPointMake(self.bounds.size.width/2, self.bounds.size.height/2)
}

var radius: CGFloat {
  return min(center.x - circleOffset, center.y - circleOffset)
}

var startPoint: CGPoint {
  var startPointX = Float(center.x) + Float(radius) * cosf(Float(startAngle))
  var startPointY = Float(center.y) + Float(radius) * sinf(Float(startAngle))
  return CGPointMake(CGFloat(startPointX), CGFloat(startPointY))
}

var cw:Int32  {
  return (startAngle > endAngle) ? 1 : 0
}


override init!(layer: AnyObject!) {
  super.init(layer: layer)
  if (layer.isKindOfClass(LoaderLayer)) {
    if let other = layer as? Loaderayer {
      startAngle = other.startAngle
      endAngle = other.endAngle
    }
  }
}

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

override class func needsDisplayForKey(key: String!) -> Bool {
  if (key == "endAngle") {
    return true
  }
  return super.needsDisplayForKey(key)
}

Having all the above set, it’s time to implement the actionForKey function. This function is called whenever a property of the class gets changed. We want to animate the change on the endAngle, so we’re calling out our very own makeAnimationForKey method when we detect change in the endAngle value.

Notice how anim.fromValue is fetched from the presentation layer in makeAnimationForKey (line 10) – it’s the last know value of the endAngle property before the animation:

override func actionForKey(event: String!) -> CAAction! {
  if event == "endAngle" {
    return makeAnimationForKey(event)
  }
  return super.actionForKey(event)
}

func makeAnimationForKey(key: String!) -> CABasicAnimation {
  var anim = CABasicAnimation(keyPath: key)
  anim.fromValue = self.presentationLayer()?.valueForKey(key)
  anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear)
  anim.duration = 0.5
  return anim
}

And, finally, here’s the drawing function itself. The most interesting part is how it uses blend modes to knock out the pie chart progress in the background (lines 12 and 26), revealing more and more of what’s underneath.

The layer automatically removes itself when the progress reaches 100%:

override func drawInContext(ctx: CGContext!) {
  if (self.endAngle < maxAngle)
  {
    var backgroundRect = CGRectMake(0, 0, bounds.size.width, bounds.size.height)
    CGContextSetBlendMode(ctx, kCGBlendModeDestinationOver)
    CGContextAddRect(ctx, backgroundRect)
    CGContextSetFillColorWithColor(ctx, UIColor(white: 0.0, alpha: 0.4).CGColor)
    CGContextFillPath(ctx)

    CGContextBeginPath(ctx)
    CGContextMoveToPoint(ctx, center.x, center.y)
    CGContextAddLineToPoint(ctx, startPoint.x, startPoint.y)
    CGContextAddArc(ctx, center.x, center.y, radius, startAngle, endAngle, cw)
    CGContextClosePath(ctx)

    CGContextSetFillColorWithColor(ctx, UIColor(white: 1.0, alpha: 1).CGColor)
    CGContextSetLineCap(ctx, kCGLineCapRound)

    CGContextSetBlendMode(ctx, kCGBlendModeDestinationOut)
    CGContextDrawPath(ctx, kCGPathFill)
  } else {
    self.removeFromSuperlayer()
  }
}

Update (Oct 14, 2015): I have wrapped a basic demo of the concept on Github. Here’s a link to the repository: ProgressDemo

11 thoughts on “Swift: Springboard-like loading animation using a custom layer”

  1. Hey,

    Can you provide an example on how you are using your custom layer in your view?

    Thanks

  2. Sure (assuming your custom layer class is named LoadingLayer):

    // in your UIViewController's viewDidLoad()
    var loadingLayer = LoadingLayer(layer: layer)
    loadingLayer.frame = view.bounds
    loadingLayer.startAngle = CGFloat(-0.5 * M_PI)
    view.layer.addSublayer(loadingLayer)
    
    
    // Here's an util function that converts
    // percentage value (0-100%) to radians
    private func calculateEndAngle(percentage: Double) -> CGFloat {
        let degrees = (percentage / 100) * 360.0
        let startAngle = (-0.5 * M_PI)
        let radians = startAngle + (degrees * (M_PI / 180))
        return CGFloat(radians)
    }
    
    // Let's set our progress to 30% complete
    loadingLayer.endAngle = self.calculateEndAngle(30.0)
    
  3. Hey,

    I tried the example and I still have some trouble getting it to work. My code is here http://pastebin.com/uTeVxTmd

    The CustomLayer.swift class contains an implementation very similar to yours. I stripped down some things that were not useful for my case, and added some extra properties that I will need later on.

    In the UIViewController I added the code as suggested in your previous comment (with one change CustomLayer(layer: view.layer), instead of CustomLayer(layer: layer) ).

    Then, in the viewDidLoad I am creating a button, attaching an action to it, and upon each touch of the button, I expect to see the pie slice animating from 0 to some random generated angle.

    The problem is I never see the pie slice animating, but only displayed in its full. Any thoughts on that? Thanks,

    Vlad

  4. Looks like your drawRandomPieSlice() reinitializes the layer every time it gets invoked (which is on every button press).

    You can create a UIViewController property to host the layer and use it to notify the layer about the updates of it’s progress:

    class ViewController: UIViewController {
      var loadingLayer:CustomLayer!
    
       override func viewDidLoad() {
         super.viewDidLoad()
         ...
         self.loadingLayer = CustomLayer(layer: view.layer)
         self.loadingLayer.frame = view.bounds
         self.loadingLayer.fillColor = Utils.getRandomColor()
         self.loadingLayer.strokeColor = UIColor.whiteColor()
         self.loadingLayer.strokeWidth = 2.0
         self.loadingLayer.startAngle = 0.0
         self.view.layer.addSublayer(self.loadingLayer)
       }
    

    and remove lines 115-121 from your pastebin code

  5. Hello, first off thanks for the great tutorial. I’m having a few issues getting it to work and I can’t figure out why.

    For one thing in makeAnimationForKey self.presentationLayer for me is always giving nil? If I set the anim.fromValue explicitly to self.endAngle than I do get animation but I am curious if you could explain why the presentation value would be nil. I am creating the layer using LoadingLayer(layer: self.layer) (note that I am creating the loading layer within a view and not a view controller, which is possibly the problem)

    If I set the value explicitly then I get almost great results, however, my animation always starts with the top right quarter of the circle filled in. This is because my startAngle is set to -0.5 * M_PI which places it at North. But the endAngle although being animated correctly to its final value, seems to always begin drawing at 0. I have printed the endAngle in my drawing function. So for instance if I wish to draw an entire circle, I set the endAngle = startingLocation + (RAD_IN_CIRCLE) //where rad in circle is CGFloat(M_PI * 2), and I see the draw call being performed starting at endAngle = 0 until endAngle = (start + RAD_IN_CIRCLE). My question is, how do I get endAngle to start at -0.5 * M_PI in the animation?

    Thank you very much for your help.

  6. Edit to my last update.

    The endValue is actually 0 and not the value I am specifying inside the makeAnimationForKey. This is likely due to the problem that I cannot get the correct value for anim.fromValue from self.presentationLayer()?.valueForKey(key) because the presentation layer is nil for some reason… So I think my real problem is just why this is nil, any ideas?

  7. Are you setting your class as a subclass of a CALayer? Also, make sure that you are setting the endAngle as @NSManaged var endAngle: CGFloat.

    I have a wrapper UIView class that looks like that:

    final class CircularProgressView: UIView {
        var progress:Double = 0.0
        var previousProgress:Double = -1.0
        var progressLayer:ProgressLayer?
    
        func advanceToProgress(progress: Double = 0.0) {
            if progress > previousProgress {
                let degrees = (progress / 100) * 360.0
                let startAngle = (-0.5 * M_PI)
                let radians = startAngle + (degrees * (M_PI / 180))
                previousProgress = progress
                progressLayer?.endAngle = CGFloat(radians)
            }
        }
    
        // Only override drawRect: if you perform custom drawing.
        // An empty implementation adversely affects performance during animation.
        override func drawRect(rect: CGRect) {
            // Drawing code
            progressLayer = ProgressLayer(layer: layer)
            progressLayer?.contentsScale = UIScreen.mainScreen().scale
            progressLayer?.frame = CGRectMake(0,0,bounds.size.width,bounds.size.height)
            progressLayer?.endAngle = CGFloat(-0.5 * M_PI)
            layer.addSublayer(progressLayer!)
        }
    }

    The ProgressLayer is where all my CALayer-related code is:

    final class ProgressLayer: CALayer {
    
        var startAngle = CGFloat(-0.5 * M_PI)
        @NSManaged var endAngle: CGFloat
        var maxAngle = CGFloat(1.5 * M_PI)
    
        var center: CGPoint {
            return CGPointMake(self.bounds.size.width/2, self.bounds.size.height/2)
        }
        var radius: CGFloat {
            return min(center.x - circleOffset, center.y - circleOffset)
        }
    
        var startPoint: CGPoint {
            let startPointX = Float(center.x) + Float(radius) * cosf(Float(startAngle))
            let startPointY = Float(center.y) + Float(radius) * sinf(Float(startAngle))
            return CGPointMake(CGFloat(startPointX), CGFloat(startPointY))
        }
    
        var cw:Int32  {
            return (startAngle > endAngle) ? 1 : 0
        }
    
        private var circleOffset:CGFloat = 4.0
    
        func makeAnimationForKey(key: String!) -> CABasicAnimation {
            let anim = CABasicAnimation(keyPath: key)
            anim.fromValue = self.presentationLayer()?.valueForKey(key)
            anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
            anim.duration = 0.3
            return anim
        }
    
        override func actionForKey(event: String) -> CAAction? {
            if event == "endAngle" {
                return makeAnimationForKey(event)
            }
            return super.actionForKey(event)
        }
    
        override init(layer: AnyObject) {
            super.init(layer: layer)
    
            if (layer.isKindOfClass(ProgressLayer)) {
                if let other = layer as? ProgressLayer {
                    startAngle = other.startAngle
                    endAngle = other.endAngle
                }
            }
        }
    
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
        }
    
        override class func needsDisplayForKey(key: String) -> Bool {
            if (key == "endAngle") {
                return true
            }
            return super.needsDisplayForKey(key)
        }
    
        override func drawInContext(ctx: CGContext) {
            if (self.endAngle < maxAngle)
            {
                let backgroundRect = CGRectMake(0, 0, bounds.size.width, bounds.size.height)
                CGContextSetBlendMode(ctx, CGBlendMode.DestinationOver)
                CGContextAddRect(ctx, backgroundRect)
                CGContextSetFillColorWithColor(ctx, UIColor(white: 0.0, alpha: 0.4).CGColor)
                CGContextFillPath(ctx)
    
                CGContextBeginPath(ctx)
                CGContextMoveToPoint(ctx, center.x, center.y)
                CGContextAddLineToPoint(ctx, startPoint.x, startPoint.y)
                CGContextAddArc(ctx, center.x, center.y, radius, startAngle, endAngle, cw)
                CGContextClosePath(ctx)
    
                CGContextSetFillColorWithColor(ctx, UIColor(white: 1.0, alpha: 1).CGColor)
                CGContextSetLineCap(ctx, CGLineCap.Round)
    
                CGContextSetBlendMode(ctx, CGBlendMode.DestinationOut)
                CGContextDrawPath(ctx, CGPathDrawingMode.Fill)
            } else {
                self.removeFromSuperlayer()
            }
        }
    }
    

    Then I assign the CircularProgressView to any UIView in the storyboard and I call the view’s advanceToProgress when my progress changes.

  8. Hi, how could a place a circle inside, so that it looks like a donut ? I’m trying to develop a wrapper similar to yours but I need a donut shape. I draw the circle in another layer inside the wrapper class and put it over the progress layer and everything seems to be ok, but if a put the wrapper view inside another view and force it’s size with constraints, the progress view won’t get resized as well. I tried to fix it in the layoutSubviews function but I could only get the progress view layer resized, not the circle

  9. Have you tried to draw the “donut” using stroke instead of filling a circle and then adding another layer?

    final class ProgressLayer: CALayer {
    
      var lineHeight:CGFloat = 4.0
      var startAngle = CGFloat(-0.5 * M_PI)
      @NSManaged var endAngle: CGFloat
      var maxAngle = CGFloat(1.5 * M_PI)
      @NSManaged var progressColor: UIColor!
    
      var center: CGPoint {
        return CGPointMake(self.bounds.size.width/2, self.bounds.size.height/2)
      }
      var radius: CGFloat {
        return min(center.x - circleOffset, center.y - circleOffset)
      }
      var startPoint: CGPoint {
        let startPointX = Float(center.x) + Float(radius) * cosf(Float(startAngle))
        let startPointY = Float(center.y) + Float(radius) * sinf(Float(startAngle))
        return CGPointMake(CGFloat(startPointX), CGFloat(startPointY))
      }
      var cw:Int32  {
        return (startAngle > endAngle) ? 1 : 0
      }
    
      private var circleOffset:CGFloat = 4.0
    
      func makeAnimationForKey(key: String!) -> CABasicAnimation {
        let anim = CABasicAnimation(keyPath: key)
        anim.fromValue = self.presentationLayer()?.valueForKey(key)
        anim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
        anim.duration = 0.3
        return anim
      }
    
      override func actionForKey(event: String) -> CAAction? {
        if event == "endAngle" {
          return makeAnimationForKey(event)
        }
        return super.actionForKey(event)
      }
    
      override init(layer: AnyObject) {
        super.init(layer: layer)
    
        if (layer.isKindOfClass(ProgressLayer)) {
          if let other = layer as? ProgressLayer {
            startAngle = other.startAngle
            endAngle = other.endAngle
          }
        }
      }
      
      required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
      }
    
      override class func needsDisplayForKey(key: String) -> Bool {
        if (key == "endAngle") {
          return true
        }
        return super.needsDisplayForKey(key)
      }
    
      override func drawInContext(ctx: CGContext) {
        CGContextBeginPath(ctx)
        CGContextAddArc(ctx, center.x, center.y, radius, startAngle, endAngle, cw)
    
        CGContextSetStrokeColorWithColor(ctx, progressColor?.CGColor)
        CGContextSetFillColorWithColor(ctx, UIColor.clearColor().CGColor)
    
        CGContextSetLineCap(ctx, CGLineCap.Round)
        CGContextSetLineWidth(ctx, lineHeight)
    
        CGContextDrawPath(ctx, CGPathDrawingMode.Stroke)
     }
    
    }

    You can wrap that layer in a UIView class so that you can add a UIView from the storyboard and assign the class to it:

    import UIKit
    
    final class CircularProgressView: UIView {
      var lineHeight:CGFloat = 4.0
      var progress:Double = 0.0
      var previousProgress:Double = -1.0
      var progressLayer:ProgressLayer?
      var progressColor:UIColor = UIColor.blueColor() {
        didSet {
          progressLayer?.progressColor = progressColor
        }
      }
    
      func advanceToProgress(progress: Double = 0.0) {
        if progress > previousProgress {
          let degrees = (progress / 100) * 360.0
          let startAngle = (-0.5 * M_PI)
          let radians = startAngle + (degrees * (M_PI / 180))
          previousProgress = progress
          progressLayer?.endAngle = CGFloat(radians)
        }
      }
    
      // Only override drawRect: if you perform custom drawing.
      // An empty implementation adversely affects performance during animation.
      override func drawRect(rect: CGRect) {
        // Drawing code
        progressLayer = ProgressLayer(layer: layer)
        progressLayer?.contentsScale = UIScreen.mainScreen().scale
        progressLayer?.progressColor = self.progressColor
        progressLayer?.lineHeight = self.lineHeight
        progressLayer?.frame = CGRectMake(0,0,bounds.size.width,bounds.size.height)
        progressLayer?.endAngle = CGFloat(-0.5 * M_PI)
        layer.addSublayer(progressLayer!)
      }
    }

    Then you just have to call yourview.advanceToProgress to update the layer

Leave a Reply

Your email address will not be published. Required fields are marked *