Recreating Skype's Action Sheet Animation

This article uses Swift 1.0.

Skype recently released a new version of its iOS app. I played with it for some time and really enjoyed the bouncy action sheet's animation:

Approach

I immediately started to think how I can recreate that effect. I didn't have a jailbroken device on hand, so I couldn't inspect the app's view hierarchy. It meant that I had to come up with my own animation. On the first look it seemed to me that the animation can be replicated with three springs "connected" to the view containing UIBezierPath and that's the approach I chose to pursue:

Springs

To animate springs we can use -animateWithDuration:delay:usingSpringWithDamping: initialSpringVelocity:options:animations:completion: introduced back in iOS 7. We'll use two helper views (named sideHelperView and centerHelperView) to observe how the animation progresses over time. We don't need a third view, because side springs are identical. Here's the complete source of the view controller at this point:

class ViewController: UIViewController {

    @IBOutlet var sideHelperView: UIView!
    @IBOutlet var centerHelperView: UIView!

    // all constraints are between the view's top and the bottom layout guide
    @IBOutlet var sideHelperTopConstraint: NSLayoutConstraint!
    @IBOutlet var centerHelperTopConstraint: NSLayoutConstraint!

    let animationDuration = 0.5

    @IBAction func toggleVisibility(sender: UIButton) {
        let actionSheetHeight: Float = 240 // will be changed later
        let hiddenTopMargin: Float = 0
        let showedTopMargin: Float = -actionSheetHeight
        let newTopMargin: Float = abs(centerHelperTopConstraint.constant - hiddenTopMargin) < 1 ? showedTopMargin : hiddenTopMargin
        let options: UIViewAnimationOptions = .BeginFromCurrentState | .AllowUserInteraction

        // Spring Type 1
        sideHelperTopConstraint.constant = newTopMargin
        UIView.animateWithDuration(animationDuration,
            delay: 0,
            usingSpringWithDamping: 0.75,
            initialSpringVelocity: 0.8,
            options: options,
            animations: {
                self.sideHelperView.layoutIfNeeded()
            }, completion:nil
        )

        // Spring Type 2
        centerHelperTopConstraint.constant = newTopMargin
        UIView.animateWithDuration(animationDuration,
            delay: 0,
            usingSpringWithDamping: 0.9,
            initialSpringVelocity: 0.9,
            options: options,
            animations: {
                self.centerHelperView.layoutIfNeeded()
            }, completion:nil
        )
    }
}

and here it is in action:

Now comes a less obvious part: how can we use positions of the helper views to drive the drawing of UIBezierPath? We need a callback on every frame of the animation to know when to redraw the path. CAAnimation doesn't provide such facility, but it can be added in either of these ways:

  1. We can leverage the fact that -drawInContext: is called for every frame of the animation; see Core animation progress callback to learn more
  2. We can use CADisplayLink, which is a special type of timer described as:

A CADisplayLink object is a timer object that allows your application to synchronize its drawing to the refresh rate of the display.

The approach with CADisplayLink looks simpler, so that's what we're going with. We'll add two properties to ViewController:

var displayLink: CADisplayLink?
var animationCount = 0

and methods for creating and destroying displayLink, so it doesn't run indefinitely: animationWillStart will be called before the animation, animationDidComplete in the animation's completion block. The animations allow user interaction, so we have to keep track of how many animations are running and invalidate displayLink only when all animations have completed:

func animationWillStart() {
    if !displayLink {
        displayLink = CADisplayLink(target: self, selector: "tick:")
        displayLink!.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSDefaultRunLoopMode)
    }

    animationCount++
}

func animationDidComplete() {
    animationCount--
    if animationCount == 0 {
        displayLink!.invalidate()
        displayLink = nil
    }
}

Bezier Path

Now, we're ready to add a bouncy view to the storyboard and outlets to it and its top constraint (between the view's top and the bottom layout guide):

@IBOutlet var bouncyView: BouncyView!
@IBOutlet var bouncyViewTopConstraint: NSLayoutConstraint!

Next, we see that -addQuadCurveToPoint:controlPoint: seems to be the easiest way of drawing the needed curve. The documentation even contains an example showing exactly what we're trying to achieve:

Drawings performed in -drawRect: are automatically clipped to the view's bounds, so we have to offset points A and C from the top to leave a spacing for a case where Control Point is above the other points. We don't need to know the exact position of Control Point, so we'll only use positions' delta (sideToCenterDelta). Final implementation of BouncyView turns out to be rather succinct:

class BouncyView: UIView {

    var sideToCenterDelta: Float = 0.0
    let fillColor = UIColor(red: 0, green: 0.722, blue: 1, alpha: 1) // blue

    override func drawRect(rect: CGRect) {
        let yOffset: Float = 20.0
        let width = CGRectGetWidth(rect)
        let height = CGRectGetHeight(rect)

        let path = UIBezierPath()
        path.moveToPoint(CGPoint(x: 0.0, y: yOffset))
        path.addQuadCurveToPoint(CGPoint(x: width, y: yOffset),
            controlPoint:CGPoint(x: width / 2.0, y: yOffset + sideToCenterDelta))
        path.addLineToPoint(CGPoint(x: width, y: height))
        path.addLineToPoint(CGPoint(x: 0.0, y: height))
        path.closePath()

        let context = UIGraphicsGetCurrentContext()
        CGContextAddPath(context, path.CGPath)
        fillColor.set()
        CGContextFillPath(context)
    }
}

Connecting the Parts

We can finally fill in -tick: method, which will be driving bouncyView's animation. We can't access helper views' frames directly because they don't represent current values during animations. We have to access the frames of their presentation layers instead. Next, we'll update bouncyViewTopConstraint, such that the vertical position of bouncyView is the same as centerHelperView and set correct sideToCenterDelta based on positions of both helper views:

func tick(displayLink: CADisplayLink) {
    let sideHelperPresentationLayer = sideHelperView.layer.presentationLayer() as CALayer
    let centerHelperPresentationLayer = centerHelperView.layer.presentationLayer() as CALayer
    let newBouncyViewTopConstraint = CGRectGetMinY(sideHelperPresentationLayer.frame) - CGRectGetMaxY(view.frame)

    bouncyViewTopConstraint.constant = newBouncyViewTopConstraint
    bouncyView.layoutIfNeeded()

    bouncyView.sideToCenterDelta = CGRectGetMinY(sideHelperPresentationLayer.frame) - CGRectGetMinY(centerHelperPresentationLayer.frame)
    bouncyView.setNeedsDisplay()
}

Conclusion

You can see the final result below. It's not exactly the same as in Skype's app, but I think it's still a nice effect achieved in a non-standard way. You can see the project on GitHub and tinker with it by changing springs' parameters.

P.S. If you're interested in seeing another approach to a similar problem, you should check out BRFlabbyTable.

UPDATE (28-06-2014): I published a follow-up post and a reusable library: AHKBendableView.