Hey! It was long time I did not post anything on Medium.
This article-tutorial was already published on iostuts.io. But I like to keep all things together at the same place, so I decided to find some time, adjust this article and publish it here as well.
This tutorial mainly explains how the elastic bounce effect was achieved in DGElasticPullToRefresh which I published a while ago:
UIBezierPath is the core of this bounce animation — it allows us to create curve using control points. To present created path on the screen I decided to use CAShapeLayer (another approach would be to override drawRect). Each control point of the bezier path should be represented as an invisible UIView. When finger moves all control point views should be moved, curve should be updated and CAShapeLayer path should be set to a new value. When finger is released all views animate to their initial position using spring animation. While views are being animated we need to observe their centres and update our CAShapeLayer — that is where CADisplayLink comes.
Here is an example of how it would look if I made all control point views red:
See second screenshot for control point views variable names
I am sure you are excited to try it!
Create a single view application and paste this code into ViewController.swift file inside a class declaration:
// MARK: -// MARK: Vars
private let minimalHeight: CGFloat = 50.0private let shapeLayer = CAShapeLayer()
// MARK: -
override func loadView() {super.loadView()
shapeLayer.frame = CGRect(x: 0.0, y: 0.0, width: view.bounds.width, height: minimalHeight)shapeLayer.backgroundColor = UIColor(red: 57/255.0, green: 67/255.0, blue: 89/255.0, alpha: 1.0)view.layer.addSublayer(shapeLayer)
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: "panGestureDidMove:"))}
// MARK: -// MARK: Methods
func panGestureDidMove(gesture: UIPanGestureRecognizer) {if gesture.state == .Ended || gesture.state == .Failed || gesture.state == .Cancelled {
} else {shapeLayer.frame.size.height = minimalHeight + max(gesture.translationInView(view).y, 0)}}
override func preferredStatusBarStyle() -> UIStatusBarStyle {return .LightContent}
What we did here was:
Build your project and check it. It should work this way:
Everything works the way we expect, apart from one thing — it changes height with a delay / animation. It is happening because of implicit animations. We definitely do not want to have that. Add this line of code just before adding shapeLayer to sublayers to disable implicit animation for position, bounds and path:
shapeLayer.actions = [“position” : NSNull(), “bounds” : NSNull(), “path” : NSNull()]
Run project and try again:
Fixed!
The next step is to add our control points views (L3, L2, L1, C, R1, R2, R3, as it was shown above) and apply all necessary logic.
Lets do it step by step:
private let maxWaveHeight: CGFloat = 100.0
The only reason we define and use this variable is to make our wave look nicer. If we do not specify the maximum value it could be too big and look awful.
private let l3ControlPointView = UIView()private let l2ControlPointView = UIView()private let l1ControlPointView = UIView()private let cControlPointView = UIView()private let r1ControlPointView = UIView()private let r2ControlPointView = UIView()private let r3ControlPointView = UIView()
l3ControlPointView.frame = CGRect(x: 0.0, y: 0.0, width: 3.0, height: 3.0)l2ControlPointView.frame = CGRect(x: 0.0, y: 0.0, width: 3.0, height: 3.0)l1ControlPointView.frame = CGRect(x: 0.0, y: 0.0, width: 3.0, height: 3.0)cControlPointView.frame = CGRect(x: 0.0, y: 0.0, width: 3.0, height: 3.0)r1ControlPointView.frame = CGRect(x: 0.0, y: 0.0, width: 3.0, height: 3.0)r2ControlPointView.frame = CGRect(x: 0.0, y: 0.0, width: 3.0, height: 3.0)r3ControlPointView.frame = CGRect(x: 0.0, y: 0.0, width: 3.0, height: 3.0)
l3ControlPointView.backgroundColor = .redColor()l2ControlPointView.backgroundColor = .redColor()l1ControlPointView.backgroundColor = .redColor()cControlPointView.backgroundColor = .redColor()r1ControlPointView.backgroundColor = .redColor()r2ControlPointView.backgroundColor = .redColor()r3ControlPointView.backgroundColor = .redColor()
view.addSubview(l3ControlPointView)view.addSubview(l2ControlPointView)view.addSubview(l1ControlPointView)view.addSubview(cControlPointView)view.addSubview(r1ControlPointView)view.addSubview(r2ControlPointView)view.addSubview(r3ControlPointView)
extension UIView {func dg_center(usePresentationLayerIfPossible: Bool) -> CGPoint {if usePresentationLayerIfPossible, let presentationLayer = layer.presentationLayer() as? CALayer {return presentationLayer.position}return center}}
When you animate UIView from one frame to another and you are trying to access UIView.frame, UIView.center it will give you the final animation value instead of the current. For this purpose we create an extension which will give us the position of UIView.layer.presentationLayer when we ask for that. Information about presentationLayer can be found here.
private func currentPath() -> CGPath {let width = view.bounds.widthlet bezierPath = UIBezierPath()
bezierPath.moveToPoint(CGPoint(x: 0.0, y: 0.0))bezierPath.addLineToPoint(CGPoint(x: 0.0, y: l3ControlPointView.dg_center(false).y))bezierPath.addCurveToPoint(l1ControlPointView.dg_center(false), controlPoint1: l3ControlPointView.dg_center(false), controlPoint2: l2ControlPointView.dg_center(false))bezierPath.addCurveToPoint(r1ControlPointView.dg_center(false), controlPoint1: cControlPointView.dg_center(false), controlPoint2: r1ControlPointView.dg_center(false))bezierPath.addCurveToPoint(r3ControlPointView.dg_center(false), controlPoint1: r1ControlPointView.dg_center(false), controlPoint2: r2ControlPointView.dg_center(false))bezierPath.addLineToPoint(CGPoint(x: width, y: 0.0))
bezierPath.closePath()
return bezierPath.CGPath}
This function returns current CGPath for shapeLayer. It uses our control points which we created and discussed earlier.
func updateShapeLayer() {shapeLayer.path = currentPath()}
This function will be called when we need shapeLayer to be updated. It is not a private func because we are going to use Selector() for CADisplayLink.
private func layoutControlPoints(baseHeight baseHeight: CGFloat, waveHeight: CGFloat, locationX: CGFloat) {let width = view.bounds.widthlet minLeftX = min((locationX - width / 2.0) * 0.28, 0.0)let maxRightX = max(width + (locationX - width / 2.0) * 0.28, width)
let leftPartWidth = locationX - minLeftXlet rightPartWidth = maxRightX - locationX
l3ControlPointView.center = CGPoint(x: minLeftX, y: baseHeight)l2ControlPointView.center = CGPoint(x: minLeftX + leftPartWidth * 0.44, y: baseHeight)l1ControlPointView.center = CGPoint(x: minLeftX + leftPartWidth * 0.71, y: baseHeight + waveHeight * 0.64)cControlPointView.center = CGPoint(x: locationX , y: baseHeight + waveHeight * 1.36)r1ControlPointView.center = CGPoint(x: maxRightX - rightPartWidth * 0.71, y: baseHeight + waveHeight * 0.64)r2ControlPointView.center = CGPoint(x: maxRightX - (rightPartWidth * 0.44), y: baseHeight)r3ControlPointView.center = CGPoint(x: maxRightX, y: baseHeight)}
This part probably requires a little bit of explanation about what each variable means. Here you go:
You may want to ask why we layout our control points in this way using these values. The answer is simple: I have used PaintCode and played with a bezier path. After I found values which I like, I put them all in code and played more to find the most appropriate ones.
func panGestureDidMove(gesture: UIPanGestureRecognizer) {if gesture.state == .Ended || gesture.state == .Failed || gesture.state == .Cancelled {} else {let additionalHeight = max(gesture.translationInView(view).y, 0)let waveHeight = min(additionalHeight * 0.6, maxWaveHeight)let baseHeight = minimalHeight + additionalHeight - waveHeightlet locationX = gesture.locationInView(gesture.view).x
layoutControlPoints(baseHeight: baseHeight, waveHeight: waveHeight, locationX: locationX)
updateShapeLayer()
}}
What we do is calculate wave height, base height, location of the finger and call our function: layoutControlPoints to layout control points and updateShapeLayer to update our shape layer path.
layoutControlPoints(baseHeight: minimalHeight, waveHeight: 0.0, locationX: view.bounds.width / 2.0)updateShapeLayer()
shapeLayer.backgroundColor = UIColor(red: 57/255.0, green: 67/255.0, blue: 89/255.0, alpha: 1.0).CGColor
To this:
shapeLayer.fillColor = UIColor(red: 57/255.0, green: 67/255.0, blue: 89/255.0, alpha: 1.0).CGColor
Run your project and check how it works. It should work this way:
The last thing left to do is to integrate our bounce animation when you release the finger.
Lets do it step by step.
private var displayLink: CADisplayLink!
and initialise it at the end of loadView method:
displayLink = CADisplayLink(target: self, selector: Selector("updateShapeLayer"))displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSDefaultRunLoopMode)displayLink.paused = true
As mentioned at the beginning, our CADisplayLink will call required function updateShapeLayer each frame, so our shapeLayer path is up to date during UIView animation.
private var animating = false {didSet {view.userInteractionEnabled = !animating displayLink.paused = !animating}}
It will enable/disable interaction and will pause/unpause displayLink.
private func currentPath() -> CGPath {let width = view.bounds.width
let bezierPath = UIBezierPath()
bezierPath.moveToPoint(CGPoint(x: 0.0, y: 0.0))bezierPath.addLineToPoint(CGPoint(x: 0.0, y: l3ControlPointView.dg_center(animating).y))
bezierPath.addCurveToPoint(l1ControlPointView.dg_center(animating), controlPoint1: l3ControlPointView.dg_center(animating), controlPoint2: l2ControlPointView.dg_center(animating))
bezierPath.addCurveToPoint(r1ControlPointView.dg_center(animating), controlPoint1: cControlPointView.dg_center(animating), controlPoint2: r1ControlPointView.dg_center(animating))
bezierPath.addCurveToPoint(r3ControlPointView.dg_center(animating), controlPoint1: r1ControlPointView.dg_center(animating), controlPoint2: r2ControlPointView.dg_center(animating))bezierPath.addLineToPoint(CGPoint(x: width, y: 0.0))
bezierPath.closePath()
return bezierPath.CGPath}
If we send true it will use the value of the presentationLayer (as explained before).
if gesture.state == .Ended || gesture.state == .Failed || gesture.state == .Cancelled {let centerY = minimalHeight
animating = trueUIView.animateWithDuration(0.9, delay: 0.0, usingSpringWithDamping: 0.57, initialSpringVelocity: 0.0, options: [], animations: { () -> Void inself.l3ControlPointView.center.y = centerYself.l2ControlPointView.center.y = centerYself.l1ControlPointView.center.y = centerYself.cControlPointView.center.y = centerYself.r1ControlPointView.center.y = centerYself.r2ControlPointView.center.y = centerYself.r3ControlPointView.center.y = centerY}, completion: { _ inself.animating = false})} else {let additionalHeight = max(gesture.translationInView(view).y, 0)
let waveHeight = min(additionalHeight * 0.6, maxWaveHeight)let baseHeight = minimalHeight + additionalHeight - waveHeight
let locationX = gesture.locationInView(gesture.view).x
layoutControlPoints(baseHeight: baseHeight, waveHeight: waveHeight, locationX: locationX)updateShapeLayer()}
We have added a UIView spring animation to animate our control point views back to their place with a really nice bounce. Have a play with these values, maybe you will make the animation even better!
Build project on your device and get ready to be amazed..
We probably do not want these dots to be visible. Remove all lines of code which are responsible for setting control point views backgroundColor and frame in loadView.
Run again..
It works perfectly, however, there is one thing which we forgot to do in this tutorial — we did not change the height of the shapeLayer, we only changed the path. It is not great and it should be fixed. I think this is great homework for you. Have a play with frame, path and all other values :)
I hope you enjoyed this tutorial. Please leave your comments and suggestions below and let me know which tutorial you would like to see next!
Source code of this tutorial can be found hereSource code of DGElasticPullToRefresh can be found here
See you next time!