Last active
February 25, 2020 23:34
-
-
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)
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 | |
/// 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() |
Author
WorldDownTown
commented
Feb 25, 2020
•
linear | ease | easeIn | easeOut | easeInOut |
---|---|---|---|---|
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment