Skip to content

Instantly share code, notes, and snippets.

@WorldDownTown
Last active February 25, 2020 23:34
Show Gist options
  • Save WorldDownTown/c60c240d778fb2e67ed73109cc5eadd1 to your computer and use it in GitHub Desktop.
Save WorldDownTown/c60c240d778fb2e67ed73109cc5eadd1 to your computer and use it in GitHub Desktop.
Solver for cubic bezier curve with implicit control points at (0, 0) and (1, 1)
import PlaygroundSupport
import UIKit
/// Solver for cubic bezier curve with implicit control points at (0, 0) and (1, 1)
/// Based on https://github.com/adobe/webkit/blob/master/Source/WebCore/platform/graphics/UnitBezier.h
struct UnitBezier {
private let a: CGPoint
private let b: CGPoint
private let c: CGPoint
private let epsilon: CGFloat = 0.000001
init(p1: CGPoint, p2: CGPoint) {
// pre-calculate the polynomial coefficients
// First and last control points are implied to be (0, 0) and (1, 1)
c = CGPoint(x: 3 * p1.x,
y: 3 * p1.y)
b = CGPoint(x: 3 * (p2.x - p1.x) - c.x,
y: 3 * (p2.y - p1.y) - c.y)
a = CGPoint(x: 1 - c.x - b.x,
y: 1 - c.y - b.y)
}
/// Find new T as a function of Y along curve X
func solve(t: CGFloat) -> CGFloat {
sampleCurveY(t: solveCurveX(t: t))
}
private func sampleCurveX(t: CGFloat) -> CGFloat {
((a.x * t + b.x) * t + c.x) * t
}
private func solveCurveXByNewtonMethod(t: CGFloat) -> CGFloat? {
var t2: CGFloat = t
var x2: CGFloat = 0
// First try a few iterations of Newton's method -- normally very fast.
for _ in 0 ..< 8 {
x2 = sampleCurveX(t: t2) - t
if abs(x2) < epsilon {
return t2
}
let derivativeT2: CGFloat = (3 * a.x * t2 + 2 * b.x) * t2 + c.x
if abs(derivativeT2) >= epsilon {
break
}
t2 -= x2 / derivativeT2
}
return nil
}
private func solveCurveXByBiSection(t arg: CGFloat) -> CGFloat {
let t: CGFloat = min(max(arg, 0), 1)
var t0: CGFloat = 0
var t1: CGFloat = 1
var t2: CGFloat = t
var x2: CGFloat = 0
while t0 < t1 {
x2 = sampleCurveX(t: t2)
if abs(x2 - t) < epsilon {
break
} else if t > x2 {
t0 = t2
} else {
t1 = t2
}
t2 = (t1 - t0) / 2 + t0
}
return t2
}
private func solveCurveX(t: CGFloat) -> CGFloat {
solveCurveXByNewtonMethod(t: t) ?? solveCurveXByBiSection(t: t)
}
private func sampleCurveY(t: CGFloat) -> CGFloat {
((a.y * t + b.y) * t + c.y) * t
}
}
extension UnitBezier {
static let linear: Self = .init(p1: .zero, p2: CGPoint(x: 1, y: 1))
static let ease: Self = .init(p1: CGPoint(x: 0.25, y: 0.1), p2: CGPoint(x: 0.25, y: 1))
static let easeIn: Self = .init(p1: CGPoint(x: 0.42, y: 0), p2: CGPoint(x: 1, y: 1))
static let easeOut: Self = .init(p1: .zero, p2: CGPoint(x: 0.58, y: 1))
static let easeInOut: Self = .init(p1: CGPoint(x: 0.42, y: 0), p2: CGPoint(x: 0.58, y: 1))
}
/// Plot points along the easing curve
final class MyViewController : UIViewController {
private let duration: TimeInterval = 2
private let bezierPath: UIBezierPath = .init()
private let shapeLayer: CAShapeLayer = {
let l: CAShapeLayer = .init()
l.fillColor = UIColor.blue.withAlphaComponent(0.5).cgColor
return l
}()
private lazy var canvas: UIView = {
let v: UIView = .init()
v.layer.borderColor = UIColor.black.cgColor
v.layer.borderWidth = 1
v.layer.addSublayer(shapeLayer)
return v
}()
private var beginningTimeInterval: TimeInterval = 0
private lazy var displayLink: CADisplayLink = {
let link: CADisplayLink = .init(target: self, selector: #selector(update(_:)))
link.preferredFramesPerSecond = 60
return link
}()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
view.addSubview(canvas)
let margin: CGFloat = 16
let edge: CGFloat = min(view.frame.width, view.frame.height) - margin * 2
canvas.frame = CGRect(x: 0,
y: 0,
width: edge,
height: edge)
canvas.center = CGPoint(x: view.frame.width / 2, y: view.frame.height / 2)
shapeLayer.frame = canvas.bounds
let button: UIButton = .init(type: .system)
button.setTitle("Start after 1sec.", for: .normal)
view.addSubview(button)
button.frame = CGRect(x: 0, y: 0, width: 200, height: 20)
button.center = CGPoint(x: view.frame.width / 2, y: canvas.frame.maxY + 20)
button.addTarget(self, action: #selector(buttonDidTap), for: .touchUpInside)
}
private func start() {
calc(0)
beginningTimeInterval = Date().timeIntervalSince1970
displayLink.isPaused = false
displayLink.add(to: .current, forMode: .common)
}
private func calc(_ t: CGFloat) {
let point: CGPoint = .init(x: canvas.frame.width * t,
y: canvas.frame.height * (1 - UnitBezier.linear.solve(t: t)))
bezierPath.append(UIBezierPath(arcCenter: point, radius: 5, startAngle: 0, endAngle: 2 * .pi, clockwise: true))
shapeLayer.path = bezierPath.cgPath
}
@objc private func update(_ link: CADisplayLink) {
let t: TimeInterval = min(1, (Date().timeIntervalSince1970 - beginningTimeInterval) / duration)
calc(CGFloat(t))
link.isPaused = t >= 1
}
@objc private func buttonDidTap() {
bezierPath.removeAllPoints()
shapeLayer.path = bezierPath.cgPath
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
self.start()
}
}
}
PlaygroundPage.current.liveView = MyViewController()
@WorldDownTown
Copy link
Author

WorldDownTown commented Feb 25, 2020

linear ease easeIn easeOut easeInOut
linear ease easeIn easeOut easeInOut

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment