Writing Custom Animations on iOS | Part III

We build professional apps for Android, iOS and mobile web.
Have a look

Writing Custom Animations on iOS w/ Swift 2.2

Part III: Putting a Window in the Wall

Disclaimer: The following post assumes you have at least read the first part of this anthology. If you haven't, please read it now so you get the context of the work we're doing today. 👋

Background

We're gonna turn it up to 11 with this one. We're going to be using some CAShapeLayer and other animations to animate this time.

Question Time

Have you ever wanted to do a shape transition? If you grew up in Australia during the 1990s, you'll have seen the awesome and often made fun of (😭) "What's in the Window" segment of Play School. Well, we're going to ramp up the cheese, and use it in our app as a top-of-the-range View Controller transition.

If you ask why, then the answer is simple - shape transitions are cool. Sometimes you want to do more than the usual Modal/Push/Pop entire-screens-of-your-app transitions, and this is a really cool way to do that.

Preview: The Diamond window:

Stage 1 - Let's have a look through the Round window...

We'll need to do some really quick setup. So create a new UIViewController subclass in Xcode, ensuring the language is set to Swift, then paste in the following:

class ViewController: UIViewController {

    var animationButton: UIButton?
    var screenSize = UIScreen.mainScreen().bounds.size

    override func viewDidLoad() {
    super.viewDidLoad()

    animationButton = UIButton(frame: CGRect(x: 0, y: 0, width: 10, height: 10))
    animationButton?.center = CGPoint(x: screenSize.width/2, y: screenSize.height/2)
    animationButton?.backgroundColor = .clearColor()
    animationButton?.layer.cornerRadius = animationButton!.frame.size.height/2
    animationButton?.setTitle("", forState: .Normal)
    animationButton?.userInteractionEnabled = false
    view.addSubview(animationButton!)
    }

}

Breakpoint

What we're doing with that subclass, is adding a reference frame for the Round window we're animating through.

The transition will begin by masking the destination view to that button, and then the shape layer created from the button's frame will be animated from that point in time until completion.

Special Note: For this transition to work correctly, both your destination and your origin View Controllers need to inherit from the subclass you created. But not for long! 💥


Moving on...

Now, we'll get the bulk of the transition done. Open up your presenting controller and paste the code below into it.

Feel free to comment out the stuff you already had from earlier parts and paste this below it.

private let scale = UIScreen.mainScreen().scale  
private let identity = CATransform3DIdentity

weak var transitionContext: UIViewControllerContextTransitioning?

func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {  
  return 2.0
}

func animateTransition(transitionContext: UIViewControllerContextTransitioning) {  
  //1
  self.transitionContext = transitionContext

  //2
  let containerView = transitionContext.containerView()
  let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey) as! ViewController
  let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey) as! ViewController
  let button = toViewController.animationButton

  //3
  containerView?.addSubview(fromViewController.view)
  containerView?.addSubview(toViewController.view)

  toViewController.view.clipsToBounds = false
  fromViewController.view.clipsToBounds = false
  toViewController.view.layer.masksToBounds = false
  fromViewController.view.layer.masksToBounds = false

  //4
  let circleMaskPathInitial = UIBezierPath(ovalInRect: button!.frame)
  let extremePoint = CGPoint(x: button!.center.x - 0, y: button!.center.y - CGRectGetHeight(toViewController.view.bounds))
  let radius = sqrt((extremePoint.x*extremePoint.x) + (extremePoint.y*extremePoint.y))
  let circleMaskPathFinal = UIBezierPath(ovalInRect: CGRectInset(button!.frame, -radius, -radius))

  //5
  let maskLayer = CAShapeLayer()
  maskLayer.path = circleMaskPathFinal.CGPath
  toViewController.view.layer.mask = maskLayer

  //6
  let maskLayerAnimation = CABasicAnimation(keyPath: "path")
  maskLayerAnimation.fromValue = circleMaskPathInitial.CGPath
  maskLayerAnimation.toValue = circleMaskPathFinal.CGPath
  maskLayerAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
  maskLayerAnimation.duration = transitionDuration(transitionContext)
  maskLayerAnimation.delegate = self

  maskLayer.addAnimation(maskLayerAnimation, forKey: "path")
}

override func animationDidStop(anim: CAAnimation, finished flag: Bool) {  
  self.transitionContext?.completeTransition(!self.transitionContext!.transitionWasCancelled())
  self.transitionContext?.viewControllerForKey(UITransitionContextFromViewControllerKey)?.view.layer.mask = nil
}

Breakpoint

If you run the app and transition between screens now, provided you've done everything correctly, you should see a slow burning circular transition taking place on screen. It's so nostalgic for Australian kids right now that they're having literal flashbacks of Play School.

If you remember, we added a subclass to our app that added an invisible and untouchable button to our View Controllers of size (10, 10) and centred. This is clearly not ideal, and will be fixed down further.

However, this button acts as our starting path for the masking shape layer. The destination view controller gets added to the screen and is masked into the size and position of that button. The path then expands to fill the screen's bounds, and the transition is complete!

We'll play with this down further, adding some motion and fixing the implementation to remove the necessity of that nasty-pastie of a subclass.


Let's have a look through the Square window...

The first thing we'll do is remove that nasty subclass. So delete it. 💥

Next, we'll modify our Presentation class to match the following:

private var portholeSize: CGSize = CGSize(width: 10, height: 10)  
    private var screenSize = UIScreen.mainScreen().bounds.size
    private let scale = UIScreen.mainScreen().scale
    private let identity = CATransform3DIdentity
    weak var transitionContext: UIViewControllerContextTransitioning?

    func transitionDuration(transitionContext: UIViewControllerContextTransitioning?) -> NSTimeInterval {
        return 2.0
    }

    func animateTransition(transitionContext: UIViewControllerContextTransitioning) {
        //1
        self.transitionContext = transitionContext

        //2
        let containerView = transitionContext.containerView()
        let fromViewController = transitionContext.viewControllerForKey(UITransitionContextFromViewControllerKey)
        let toViewController = transitionContext.viewControllerForKey(UITransitionContextToViewControllerKey)

        //3
        containerView?.addSubview(fromViewController!.view)
        containerView?.addSubview(toViewController!.view)

        toViewController?.view.clipsToBounds = false
        fromViewController?.view.clipsToBounds = false
        toViewController?.view.layer.masksToBounds = false
        fromViewController?.view.layer.masksToBounds = false

        //4
        let squareMaskPathInitial = UIBezierPath(rect: CGRect(x: (screenSize.width/2)-(portholeSize.width/2), y: (screenSize.height/2)-(portholeSize.height/2), width: portholeSize.width, height: portholeSize.height))
        let extremePoint = CGPoint(x: (screenSize.width/2) - 0, y: (screenSize.height/2) - CGRectGetHeight(toViewController!.view.bounds))
        let radius = sqrt((extremePoint.x*extremePoint.x) + (extremePoint.y*extremePoint.y))
        let squareMaskPathFinal = UIBezierPath(rect: CGRectInset(CGRect(x: (screenSize.width/2)-(portholeSize.width/2), y: (screenSize.height/2)-(portholeSize.height/2), width: portholeSize.width, height: portholeSize.height), -radius, -radius))

        //5
        let maskLayer = CAShapeLayer()
        maskLayer.path = squareMaskPathFinal.CGPath
        toViewController?.view.layer.mask = maskLayer

        //6
        let maskLayerAnimation = CABasicAnimation(keyPath: "path")
        maskLayerAnimation.fromValue = squareMaskPathInitial.CGPath
        maskLayerAnimation.toValue = squareMaskPathFinal.CGPath
        maskLayerAnimation.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
        maskLayerAnimation.duration = transitionDuration(transitionContext)
        maskLayerAnimation.delegate = self

        maskLayer.addAnimation(maskLayerAnimation, forKey: "path")
    }

    override func animationDidStop(anim: CAAnimation, finished flag: Bool) {
        self.transitionContext?.completeTransition(!self.transitionContext!.transitionWasCancelled())
        self.transitionContext?.viewControllerForKey(UITransitionContextFromViewControllerKey)?.view.layer.mask = nil
    }
Breakpoint
  • You'll notice that we removed all reference to that awful UIViewController subclass.
  • That hacky as hell method of having a button on every View Controller is now gone.
  • The circle transition has now become a square variant, just like the square window from Play School. 😂
  • We now have some pre-determined and modifiable sizes of our "porthole" (aren't my analogies brilliant?)
  • This opens the doors for more complicated UIBezierPaths! 💥

Let's have a look through the Diamond window...

To have a diamond shaped transition, replace your Path declarations with the following. They're not perfect, but you get the idea. 🎉

let diamondMaskPathInitial = UIBezierPath()  
diamondMaskPathInitial.moveToPoint(CGPoint(x: screenSize.width/2, y: (screenSize.height/2)-(portholeSize.height/2)))  
diamondMaskPathInitial.addLineToPoint(CGPoint(x: (screenSize.width/2)+(portholeSize.width/2), y: (screenSize.height/2)))  
diamondMaskPathInitial.addLineToPoint(CGPoint(x: screenSize.width/2, y: (screenSize.height/2)+(portholeSize.height/2)))  
diamondMaskPathInitial.addLineToPoint(CGPoint(x: (screenSize.width/2)-(portholeSize.width/2), y: (screenSize.height/2)))  
diamondMaskPathInitial.closePath()

let diamondMaskPathFinal = UIBezierPath()  
diamondMaskPathFinal.moveToPoint(CGPoint(x: screenSize.width/2, y: -(screenSize.height/2)))  
diamondMaskPathFinal.addLineToPoint(CGPoint(x: screenSize.width+(screenSize.height/2), y: screenSize.height/2))  
diamondMaskPathFinal.addLineToPoint(CGPoint(x: screenSize.width/2, y: screenSize.height+(screenSize.height/2)))  
diamondMaskPathFinal.addLineToPoint(CGPoint(x: 0.0-(screenSize.height/2), y: screenSize.height/2))  
diamondMaskPathFinal.closePath()  

If you run the app, you'll see a diamond shaped transition taking place.


Challenge: But what about the Arched window???

Given this UIBezierPath code, which is a small arch:

let bezierPath = UIBezierPath()  
bezierPath.moveToPoint(CGPoint(x: 131.5, y: 234.5))  
bezierPath.addLineToPoint(CGPoint(x: 131.5, y: 273.5))  
bezierPath.addLineToPoint(CGPoint(x: 160.5, y: 273.5))  
bezierPath.addLineToPoint(CGPoint(x: 189.5, y: 273.5))  
bezierPath.addLineToPoint(CGPoint(x: 189.5, y: 235.5))  
bezierPath.addCurveToPoint(CGPoint(x: 160.5, y: 206.5), controlPoint1: CGPoint(x: 189.5, y: 235.5), controlPoint2: CGPoint(x: 189.5, y: 206.5))  
bezierPath.addCurveToPoint(CGPoint(x: 131.5, y: 234.5), controlPoint1: CGPoint(x: 131.5, y: 206.5), controlPoint2: CGPoint(x: 131.5, y: 234.5))  
bezierPath.closePath()  

Which was drawn in the center of a 320pt x 480pt screen space (oh the nostalgia!), how would you go about making a large one work?

Extra Challenge: Make everything reverse when it should! 😈


Conclusion

If you go really crazy and use a really complex UIBezierPath such as the Swift Bird or something else with this, if you find yourself bereft of PaintCode it's a simple fact that remembering how to scale your path up will suck. Badly.

In the next part, we'll discuss how to use more complex shapes and our good old friend CATransform3D to assist us in making the transitions a little less ugly.