Recreating Skype's Action Sheet Animation
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:
Display Link
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:
- We can leverage the fact that
-drawInContext:
is called for every frame of the animation; see Core animation progress callback to learn more - 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.