Skip to content

Instantly share code, notes, and snippets.

@warpling
Last active August 1, 2019 20:11
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save warpling/5035bc6d2f42db1aa33b58ff09d1dc57 to your computer and use it in GitHub Desktop.
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)
//: 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