Categories
Manuals

How to create a percentage driven animation

Interactive animation can get a bit tricky when the animation becomes complex (key frame animations with several layers).
In this tutorial I will show you how to create a complex percentage driven interactive animation.

Prerequisites / Requirements

  • Xcode 7
  • Swift 2.0
  • For this tutorial I will assume that you are already familiar with CAAnimationGroup, CABasicAnimation, CAKeyframeAnimation

Percentage driven animation

Let’s start with some background knowledge about CAMediaTiming. CAMediaTiming is a protocol that CAAnimation implements but the same protocol is also implemented by CALayer, the base class of all Core Animation layers. This means that you can set the speed of a layer to 2.0 and all animations that are added to it will run twice as fast. Controlling the speed of an animation or a layer can also be used to pause the animation by setting the speed to 0. Together with timeOffset this can control an animation from an external input like a PanGesture, UIScrollView, slider.

Switch animation

We start by creating a subclass of UIView that will contain our animation. The animation is similar to the native iOS switch.

This animation contains three parts:

  • Thumb layer

It will be animated from left to right, on/off

  • The background + stroke layer The stroke animates fade from gray to green
    The fill color animates from white to green
  • The background mask The path animates from current shape to small dot

We setup each layer and add them to the main view layer.

 override func setupLayers() {
        super.setupLayers()

        width = self.frame.width
        height = self.frame.height

        strokeBackgroundLayer.path = backgroundPath(CGRect(x: 0,y: 0,width: width,height: height), radius: 50/2).CGPath
        strokeBackgroundLayer.strokeColor = strokeColor.CGColor
        strokeBackgroundLayer.fillColor = selectedColor.CGColor
        strokeBackgroundLayer.lineWidth = 2

        backgroundLayer.path = backgroundPath(CGRect(x: 1,y: 1,width: width-2,height: height-2), radius: 50/2).CGPath
        backgroundLayer.fillColor = UIColor.whiteColor().CGColor

        thumbLayer.path = UIBezierPath(ovalInRect: CGRect(x: thumbInset/2, y: thumbInset/2, width: height-thumbInset, height: height-thumbInset)).CGPath
        thumbLayer.strokeColor = strokeColor.CGColor
        thumbLayer.fillColor = UIColor.whiteColor().CGColor
        thumbLayer.lineWidth = 0.5
        thumbLayer.shadowColor = UIColor.blackColor().CGColor
        thumbLayer.shadowOpacity = 0.2
        thumbLayer.shadowRadius = 1
        thumbLayer.shadowOffset = CGSizeMake(0, 1)

        layer.addSublayer(strokeBackgroundLayer)
        layer.addSublayer(backgroundLayer)
        layer.addSublayer(thumbLayer)
    }

Now we are ready to create the animations for each layer. The background + stroke needs 2 animations, a stroke colour and a fill colour. We will use CAAnimationGroup to add several animations to the same layer.

 // MARK: ANIMATION LAYERS

    // MARK: STROKE
    func strokeBackgroundAnimations() -> CAAnimationGroup {
        let groupAnimation = CAAnimationGroup()
        groupAnimation.duration = animDuration
        groupAnimation.animations = [strokeColorAnimation(), strokeFillColorAnimation()]
        groupAnimation.removedOnCompletion = false;

        return groupAnimation
    }

    func strokeColorAnimation()-> CABasicAnimation {

        let strokeAnim = CABasicAnimation(keyPath: "strokeColor")
        strokeAnim.fromValue = strokeColor.CGColor
        strokeAnim.toValue = selectedColor.CGColor
        strokeAnim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        return strokeAnim
    }
    func strokeFillColorAnimation()-> CABasicAnimation {

        let fillAnim = CABasicAnimation(keyPath: "fillColor")
        fillAnim.fromValue = UIColor.whiteColor().CGColor
        fillAnim.toValue = selectedColor.CGColor
        fillAnim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        return fillAnim
    }

The thumb and background only need one animation but let’s keep them in a group animation so it’s easier if you want to add to them later on.

    // MARK: BACKGROUND
    func backgroundAnimations() -> CAAnimationGroup {
        let groupAnimation = CAAnimationGroup()
        groupAnimation.duration = animDuration
        groupAnimation.animations = [backgroundFillAnimation()]
        groupAnimation.removedOnCompletion = false;

        return groupAnimation
    }

    func backgroundFillAnimation()-> CAKeyframeAnimation {

        let endPath = backgroundPath(CGRect(x: 1, y: 1, width: width-2, height: height-2), radius: 0)
        let beginPath = backgroundPath(CGRect(x: width/2, y: height/2, width: 0, height: 0), radius: 0)

        let fillAnim = CAKeyframeAnimation(keyPath: "path")
        fillAnim.values = [
            endPath.CGPath,
            beginPath.CGPath]

        fillAnim.keyTimes  = [
            NSNumber(float: 0.0),
            NSNumber(float: 1.0)]

        fillAnim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        return fillAnim
    }

    func backgroundPath(frame: CGRect, radius: CGFloat) -> UIBezierPath {
        //// Rectangle Drawing

        let rectanglePath = UIBezierPath(roundedRect: CGRect(x: frame.origin.x, y: frame.origin.y, width: frame.width, height: frame.height), cornerRadius: frame.height/2)
        return rectanglePath
    }
    // MARK: THUMB
    func thumbAnimations() -> CAAnimationGroup {
        let groupAnimation = CAAnimationGroup()
        groupAnimation.duration = animDuration
        groupAnimation.animations = [thumbPositionAnimation()]
        groupAnimation.removedOnCompletion = false;
        return groupAnimation
    }

    func thumbPositionAnimation()-> CABasicAnimation {
        let posAnim = CABasicAnimation(keyPath: "position")
        posAnim.fromValue = NSValue(CGPoint:CGPointMake(0, 0))
        posAnim.toValue = NSValue(CGPoint:CGPointMake( (width - height), 0))
        posAnim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        return posAnim
    }

Now we have our animations ready, we will add the animations to their respective layers. We will also have to pause the animations so they don’t start to play.

To make this class more reusable we create an array of CALayer with animation.
var layerWithAnims : [CALayer]!

Let’s add our layers to the array at the end of setupLayer method
self.layerWithAnims = [strokeBackgroundLayer, backgroundLayer, thumbLayer]

The code below pauses each layer that contains an animation and adds each animation to the layer.

    //override and add your layers with animation
    func startAllAnimations(){
        for layer in self.layerWithAnims{
            layer.speed = 0
        }

        //ADD YOUR ANIMATIONS TO THE LAYER          strokeBackgroundLayer.addAnimation(strokeBackgroundAnimations(), forKey: "strokeBackgroundAnimations")
        backgroundLayer.addAnimation(backgroundAnimations(), forKey: "backgroundAnimations")
        thumbLayer.addAnimation(thumbAnimations(), forKey: "thumbAnimations")
    }
Interactive animation

To control the animation we use the timeOffset.
Let’s create a progress property that will let us set the progress of the animation.

if no animation has been added to the layer we call startAllAnimation and make sure we pause the animations for each layer.

If the animations are already added we set the timeoffset of each layer to be the current progress.

The total duration of the animation multiplied by the progress equals the current timeoffset
let offset = progress * CGFloat(animDuration)

    var progress: CGFloat = 0 {
        didSet{
            if(!self.animationAdded){
                startAllAnimations()
                self.animationAdded = true
                for layer in self.layerWithAnims{
                    layer.speed = 0
                    layer.timeOffset = 0
                }
            }
            else{
                let offset = progress * CGFloat(animDuration)
                for layer in self.layerWithAnims{
                    layer.timeOffset = CFTimeInterval(offset)
                }
            }
        }
    }

By setting the progress you are now able to control this animation. You could for example use panGesture or a slider to get the user input and set the animation progress.

I’ve made a library to create a custom switch using the same percentage driven animation technics. Here is an example of what can be achieved with more complex animations.
This DayNight switch was inspired from a dribble animation

Feel free to add your amazing animations to this
WACustomSwitch library available on github

Leave a Reply

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