Last active
February 24, 2020 13:17
-
-
Save WorldDownTown/5b305bf546fdb5d510be003a5a14e305 to your computer and use it in GitHub Desktop.
Checkmark with ripple animation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import PlaygroundSupport | |
import UIKit | |
final class RippleCheckmark: UIView { | |
private let circleLayer: CAShapeLayer = .init() | |
private let rippleLayer: CAShapeLayer = .init() | |
private let checkmarkLayer: CAShapeLayer = .init() | |
private var completion: (() -> Void)? | |
var fillColor: UIColor = .blue { | |
didSet { update() } | |
} | |
var strokeColor: UIColor = .white { | |
didSet { update() } | |
} | |
required init?(coder aDecoder: NSCoder) { | |
super.init(coder: aDecoder) | |
setup() | |
} | |
override init(frame: CGRect) { | |
super.init(frame: frame) | |
setup() | |
} | |
override func layoutSubviews() { | |
super.layoutSubviews() | |
let position: CGPoint = .init(x: bounds.midX, y: bounds.midY) | |
rippleLayer.bounds = layer.bounds | |
rippleLayer.position = position | |
rippleLayer.path = UIBezierPath(ovalIn: rippleLayer.bounds).cgPath | |
circleLayer.bounds = layer.bounds | |
circleLayer.position = position | |
circleLayer.path = UIBezierPath(ovalIn: circleLayer.bounds).cgPath | |
checkmarkLayer.bounds = circleLayer.bounds | |
checkmarkLayer.position = position | |
checkmarkLayer.path = checkmarkPath().cgPath | |
} | |
func startAnimation(completion: @escaping () -> Void) { | |
self.completion = completion | |
checkmarkLayer.add(makeStrokeAnimation(), forKey: nil) | |
circleLayer.add(makeScaleAnimation(), forKey: nil) | |
let rippleAnimation: CAAnimationGroup = makeRippleAnimation() | |
rippleAnimation.delegate = self | |
rippleLayer.add(rippleAnimation, forKey: nil) | |
} | |
private func setup() { | |
backgroundColor = .clear | |
layer.addSublayer(rippleLayer) | |
checkmarkLayer.fillColor = UIColor.clear.cgColor | |
checkmarkLayer.lineWidth = 2 | |
checkmarkLayer.lineCap = .round | |
circleLayer.addSublayer(checkmarkLayer) | |
layer.addSublayer(circleLayer) | |
update() | |
} | |
private func checkmarkPath() -> UIBezierPath { | |
let width: CGFloat = bounds.width | |
let height: CGFloat = bounds.height | |
let path: UIBezierPath = .init() | |
path.move(to: CGPoint(x: width * 0.25, y: height * 0.525)) | |
path.addLine(to: CGPoint(x: width * 0.4, y: height * 0.675)) | |
path.addLine(to: CGPoint(x: width * 0.725, y: height * 0.35)) | |
return path | |
} | |
private func update() { | |
rippleLayer.fillColor = fillColor.withAlphaComponent(0.2).cgColor | |
circleLayer.fillColor = fillColor.cgColor | |
checkmarkLayer.strokeColor = strokeColor.cgColor | |
checkmarkLayer.fillColor = fillColor.cgColor | |
} | |
private func makeStrokeAnimation() -> CABasicAnimation { | |
let animation: CABasicAnimation = .init(keyPath: #keyPath(CAShapeLayer.strokeEnd)) | |
animation.duration = 0.4 | |
animation.fromValue = 0 | |
animation.toValue = 1 | |
animation.isRemovedOnCompletion = false | |
animation.fillMode = .forwards | |
return animation | |
} | |
private func makeScaleAnimation() -> CASpringAnimation { | |
let animation: CASpringAnimation = .init(keyPath: #keyPath(CALayer.transform)) | |
animation.fromValue = CATransform3DMakeScale(0.5, 0.5, 1) | |
animation.toValue = CATransform3DIdentity | |
animation.isRemovedOnCompletion = false | |
animation.fillMode = .forwards | |
animation.duration = animation.settlingDuration | |
animation.damping = 11.96 | |
animation.initialVelocity = -3.64 | |
animation.mass = 0.48 | |
animation.stiffness = 300 | |
return animation | |
} | |
private func makeRippleAnimation() -> CAAnimationGroup { | |
let animation1: CABasicAnimation = .init(keyPath: #keyPath(CALayer.transform)) | |
animation1.duration = 1 | |
animation1.fromValue = CATransform3DIdentity | |
animation1.toValue = CATransform3DMakeScale(2, 2, 1) | |
animation1.isRemovedOnCompletion = false | |
animation1.fillMode = .forwards | |
let animation2: CABasicAnimation = .init(keyPath: #keyPath(CALayer.opacity)) | |
animation2.duration = 1 | |
animation2.fromValue = 1 | |
animation2.toValue = 0 | |
animation2.isRemovedOnCompletion = false | |
animation2.fillMode = .forwards | |
let animationGroup: CAAnimationGroup = .init() | |
animationGroup.animations = [animation1, animation2] | |
animationGroup.duration = 1 | |
animationGroup.isRemovedOnCompletion = false | |
animationGroup.fillMode = .forwards | |
animationGroup.timingFunction = CAMediaTimingFunction(name: .easeOut) | |
return animationGroup | |
} | |
} | |
// MARK: - CAAnimationDelegate | |
extension RippleCheckmark: CAAnimationDelegate { | |
func animationDidStop(_ animation: CAAnimation, finished flag: Bool) { | |
completion?() | |
completion = nil | |
rippleLayer.removeAllAnimations() | |
} | |
} | |
final class MyViewController : UIViewController { | |
private let checkmark: RippleCheckmark = .init(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) | |
override func viewDidLoad() { | |
super.viewDidLoad() | |
view.backgroundColor = .white | |
checkmark.fillColor = .black | |
} | |
override func viewWillAppear(_ animated: Bool) { | |
super.viewWillAppear(animated) | |
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { | |
self.startAnimation() | |
} | |
} | |
private func startAnimation() { | |
checkmark.removeFromSuperview() | |
view.addSubview(checkmark) | |
checkmark.center = CGPoint(x: view.frame.width * 0.5, y: view.frame.height * 0.25) | |
checkmark.startAnimation { | |
} | |
} | |
} | |
PlaygroundPage.current.liveView = MyViewController() |
Author
WorldDownTown
commented
Feb 24, 2020
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment