Last active
August 1, 2019 20:11
-
-
Save warpling/5035bc6d2f42db1aa33b58ff09d1dc57 to your computer and use it in GitHub Desktop.
Copying Tinder's "mentos" button animation (based on tweet: https://twitter.com/warpling/status/930567671015358464?s=20)
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
//: Playground - noun: a place where people can play | |
import UIKit | |
class Mento: UIView { | |
// The thickness ratio of our mento, 1.0 being a perfect sphere. | |
let mentoThicknessScale: CGFloat = 0.60 | |
let shape: UIView = { | |
let shape = UIView() | |
shape.backgroundColor = .gray | |
return shape | |
}() | |
var frontContent = UIView() | |
var backContent = UIView() | |
var isFrontwards = true | |
// This is the initializer if you choose to use an icon for the front/back | |
init(size: CGFloat, color: UIColor = .gray, frontIcon: UIImage, backIcon: UIImage? = nil) { | |
super.init(frame: CGRect(origin: .zero, size: CGSize(width: size, height: size))) | |
let iconView = UIImageView(image: frontIcon) | |
iconView.contentMode = .scaleAspectFit | |
iconView.frame = bounds.insetBy(dx: bounds.width/2, dy: bounds.height/2) | |
frontContent = iconView | |
addSubview(frontContent) | |
if let backIcon = backIcon { | |
let backIconView = UIImageView(image: backIcon) | |
backIconView.contentMode = .scaleAspectFit | |
backIconView.frame = bounds.insetBy(dx: bounds.width/2, dy: bounds.height/2) | |
backContent = backIconView | |
addSubview(backContent) | |
} | |
shape.backgroundColor = color | |
} | |
// This is the initializer if you choose to use an text for the front/back | |
init(size: CGFloat, color: UIColor = .gray, frontText: String = "★", backText: String = "") { | |
frontContent = UILabel() | |
backContent = UILabel() | |
super.init(frame: CGRect(origin: .zero, size: CGSize(width: size, height: size))) | |
addSubview(shape) | |
addSubview(frontContent) | |
addSubview(backContent) | |
backContent.alpha = 0 | |
shape.backgroundColor = color | |
(frontContent as! UILabel).text = frontText | |
(backContent as! UILabel).text = backText | |
([frontContent, backContent] as! [UILabel]).forEach { (label) in | |
label.textAlignment = .center | |
label.textColor = .white | |
label.font = UIFont.boldSystemFont(ofSize: bounds.height/2) | |
label.sizeToFit() | |
label.center = center | |
} | |
} | |
override var frame: CGRect { | |
didSet { | |
let maskLayer = CAShapeLayer() | |
maskLayer.path = UIBezierPath(ovalIn: bounds).cgPath | |
shape.frame = bounds | |
shape.layer.mask = maskLayer | |
} | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
// Allows us to use the mento in AutoLayout environments | |
override var intrinsicContentSize: CGSize { | |
return bounds.size | |
} | |
public func animate(flips: Int = 1, duration: TimeInterval, easing: UIView.AnimationOptions = .curveEaseInOut, _ completion: ((Bool) -> Void)? = nil) { | |
// How wide and tall the mento is in XY space | |
let mentoWidth: CGFloat = bounds.width | |
// How *tall* the mento is in Z space | |
let mentoThickness: CGFloat = mentoThicknessScale * shape.bounds.width | |
// Pushing the label slightly away from the surface of the shape view can help with some transforms | |
let labelTranslateZ: CGFloat = 1 | |
let easing = UIView.KeyframeAnimationOptions(rawValue: easing.rawValue) | |
UIView.animateKeyframes(withDuration: duration, delay: 0, options: easing, animations: { | |
// A slip is one 180˚ turn | |
for flip in 1...flips { | |
let turnSidewaysDuration = self.relativeDuration(flip: flip, outOf: flips) / 2 | |
let turnSidewaysStartTime = self.relativeStartTime(flip: flip, outOf: flips) | |
let finishTurnDuration = turnSidewaysDuration | |
let finishTurnStartTime = turnSidewaysStartTime + finishTurnDuration | |
// Deterines how much of the mento rotation time is used to translate, scale, and fade the icon | |
// Values lower than 1.0 and greater than 0.5 can help with the 3D illusion | |
let iconAnimationCoefficient: CGFloat = 0.8 | |
// Turn 90˚ so the skinny side of the mento is facing us | |
UIView.addKeyframe(withRelativeStartTime: turnSidewaysStartTime, | |
relativeDuration: turnSidewaysDuration) { | |
self.shape.layer.transform = CATransform3DMakeScale(self.mentoThicknessScale, 1, 1) | |
} | |
UIView.addKeyframe(withRelativeStartTime: turnSidewaysStartTime, | |
relativeDuration: turnSidewaysDuration * Double(iconAnimationCoefficient)) { | |
let labelTranslateX = -(mentoWidth - (iconAnimationCoefficient * (mentoWidth - mentoThickness))) / 2 | |
var transform = CATransform3DMakeTranslation(labelTranslateX, 0, labelTranslateZ) | |
transform = CATransform3DScale(transform, 0.001, 0.92, 1) | |
self.frontContent.layer.transform = transform | |
self.backContent.layer.transform = transform | |
self.frontContent.alpha = self.isFrontwards ? 0.4 : 0 | |
self.backContent.alpha = self.isFrontwards ? 0 : 0.4 | |
} | |
// Prepare to turn 90˚ farther so the back of the mento is facing us | |
UIView.addKeyframe(withRelativeStartTime: finishTurnStartTime, | |
relativeDuration: 0.0001) { | |
// Move the label to the other side of the mento so it can appear to roll around | |
let labelTranslateX = mentoThickness/2 + ((1.0 - iconAnimationCoefficient) * mentoThickness/2) | |
var transform = CATransform3DMakeTranslation(labelTranslateX, 0, labelTranslateZ) | |
transform = CATransform3DScale(transform, 0.001, 0.92, 1) | |
self.frontContent.layer.transform = transform | |
self.backContent.layer.transform = transform | |
self.frontContent.alpha = 0 | |
self.backContent.alpha = 0 | |
} | |
// Turn back so the back of the mento is facing us | |
UIView.addKeyframe(withRelativeStartTime: finishTurnStartTime, | |
relativeDuration: finishTurnDuration) { | |
self.shape.layer.transform = CATransform3DIdentity | |
} | |
UIView.addKeyframe(withRelativeStartTime: finishTurnStartTime + finishTurnDuration * (1.0 - Double(iconAnimationCoefficient)), | |
relativeDuration: finishTurnDuration * Double(iconAnimationCoefficient)) { | |
self.frontContent.alpha = self.isFrontwards ? 0 : 1 | |
self.backContent.alpha = self.isFrontwards ? 1 : 0 | |
self.frontContent.layer.transform = CATransform3DMakeTranslation(0, 0, labelTranslateZ) | |
self.backContent.layer.transform = CATransform3DMakeTranslation(0, 0, labelTranslateZ) | |
} | |
self.isFrontwards.toggle() | |
} | |
}, completion: completion) | |
} | |
// A flip is one 180˚ rotation. The first flip is flip #1, | |
// so to rotate the mento 360˚ takes two flips. The calls | |
// to this function for those two flips will be relativeDuration(flip: 1, outOf: 2), | |
// and relativeDuration(flip: 2, outOf: 2). | |
func relativeDuration(flip: Int, outOf totalFlips: Int) -> TimeInterval { | |
let nextFlipStartTime = relativeStartTime(flip: flip + 1, outOf: totalFlips) | |
let thisFlipStartTime = relativeStartTime(flip: flip, outOf: totalFlips) | |
return nextFlipStartTime - thisFlipStartTime | |
} | |
// Use this function if you want to adjust the spacing/duration of keyframes themselves to be non-linear. | |
// By default the animation uses a smooth ease-in-out to make the linear keyframes move with easing and this looks | |
// pretty good. You can alternative try to provide your own timing curve here by passing `.calculationModeCubic` to | |
// the animation options and providing a non-linear function here. I wouldn't really recommend it though. | |
func relativeStartTime(flip: Int, outOf totalFlips: Int) -> TimeInterval { | |
let x = Float(flip-1) / Float(totalFlips) | |
// Linear | |
return Double(x) | |
// Ease-in-out | |
// return Double(pow(sin(5*x / 3.1415), 2.0)) | |
// More adjustable ease-in-out like function | |
// https://math.stackexchange.com/a/121755 | |
// let alpha: Float = 3.0 | |
// return Double(pow(x, alpha)/(pow(x, alpha) + pow(1-x, alpha))) | |
} | |
} | |
class MentosPack: UIView { | |
let mentoA = Mento(size: 100, color: .orange, frontText: "★", backText: "■") | |
let mentoB = Mento(size: 44, color: .magenta, frontText: "★") | |
let mentoC = Mento(size: 80, color: .cyan, frontText: "😜", backText: "😈") | |
let mentosStack = UIStackView() | |
init() { | |
super.init(frame: CGRect(x: 0, y: 0, width: 500, height: 200)) | |
backgroundColor = .white | |
mentosStack.frame = bounds.insetBy(dx: 50, dy: 0) | |
mentosStack.axis = .horizontal | |
mentosStack.spacing = 50 | |
mentosStack.distribution = .equalSpacing | |
mentosStack.alignment = .center | |
mentosStack.addArrangedSubview(mentoA) | |
mentosStack.addArrangedSubview(mentoB) | |
mentosStack.addArrangedSubview(mentoC) | |
addSubview(mentosStack) | |
mentoA.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateA))) | |
mentoB.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateB))) | |
mentoC.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(animateC))) | |
} | |
@objc func animateA(_ gesture: UITapGestureRecognizer) { | |
if gesture.state == .ended { | |
mentoA.animate(flips: 2, duration: 3, easing: .curveEaseOut) | |
} | |
} | |
@objc func animateB(_ gesture: UITapGestureRecognizer) { | |
if gesture.state == .ended { | |
mentoB.animate(flips: 8, duration: 3) | |
} | |
} | |
@objc func animateC(_ gesture: UITapGestureRecognizer) { | |
if gesture.state == .ended { | |
mentoC.animate(flips: 3, duration: 1.2) | |
} | |
} | |
required init?(coder: NSCoder) { | |
fatalError("init(coder:) has not been implemented") | |
} | |
} | |
import PlaygroundSupport | |
let vc = UIViewController() | |
vc.preferredContentSize = CGSize(width: 500, height: 200) | |
vc.view.addSubview(MentosPack()) | |
PlaygroundPage.current.liveView = vc | |
//PlaygroundPage.current.needsIndefiniteExecution = true |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment