Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
/**
* BezierEasing - use bezier curve for transition easing function
* https://github.com/gre/bezier-easing
* @copyright 2014-2015 Gaetan Renaudeau. MIT License.
* @copyright 2014-2026 Gaetan Renaudeau. MIT License.
*/

import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment';
Expand Down Expand Up @@ -110,6 +110,54 @@ describe('bezier', function () {
});
});
});
describe('precision', function () {
it('should match points of the curve', function () {
repeat(10)(function () {
const a = Math.random(),
b = 2 * Math.random() - 0.5,
c = Math.random(),
d = 2 * Math.random() - 0.5;
const easing = bezier(a, b, c, d);
for (let i = 1; i < 100; ++i) {
const t = i / 100;
const x =
3 * a * t * (1 - t) * (1 - t) + 3 * c * t * t * (1 - t) + t * t * t;
const y =
3 * b * t * (1 - t) * (1 - t) + 3 * d * t * t * (1 - t) + t * t * t;
assertClose(easing(x), y, 8);
}
});
});
it('should be precise near the extremes', function () {
// x(t) = t³, y(t) = t² (3 - 2t)
const easing = bezier(0, 0, 0, 1);
for (const t of [1e-6, 1e-3, 0.999]) {
// relative precision
assertClose(easing(t * t * t) / (t * t * (3 - 2 * t)), 1, 10);
}
expect(easing(Number.MIN_VALUE)).toBeGreaterThan(0);
});
it('should be monotonic on steep curves', function () {
const easing = bezier(1, 0, 0, 1);
let previous = 0;
for (let i = 1; i <= 10000; ++i) {
const y = easing(0.49 + (0.02 * i) / 10000);
expect(y).toBeGreaterThanOrEqual(previous);
previous = y;
}
});
});
describe('outside of [0, 1]', function () {
it('should saturate to 0 / 1', function () {
const easing = bezier(0.25, 0.1, 0.25, 1);
expect(easing(-0.5)).toBe(0);
expect(easing(1.5)).toBe(1);
expect(Number.isNaN(easing(NaN))).toBe(true);
});
it('should keep linear curves as the identity', function () {
expect(bezier(0.3, 0.3, 0.6, 0.6)(-0.5)).toBe(-0.5);
});
});
describe('two same instances', function () {
it('should be strictly equals', function () {
repeat(10)(function () {
Expand Down
163 changes: 39 additions & 124 deletions packages/react-native/Libraries/Animated/bezier.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,85 +11,34 @@
/**
* BezierEasing - use bezier curve for transition easing function
* https://github.com/gre/bezier-easing
* @copyright 2014-2015 Gaëtan Renaudeau. MIT License.
* @copyright 2014-2026 Gaëtan Renaudeau. MIT License.
*/

'use strict';

// These values are established by empiricism with tests (tradeoff: performance VS precision)
const NEWTON_ITERATIONS = 4;
const NEWTON_MIN_SLOPE = 0.001;
const SUBDIVISION_PRECISION = 0.0000001;
const SUBDIVISION_MAX_ITERATIONS = 10;

const kSplineTableSize = 11;
const kSampleStepSize = 1.0 / (kSplineTableSize - 1.0);

const float32ArraySupported = typeof Float32Array === 'function';

function A(aA1: number, aA2: number) {
return 1.0 - 3.0 * aA2 + 3.0 * aA1;
}
function B(aA1: number, aA2: number) {
return 3.0 * aA2 - 6.0 * aA1;
}
function C(aA1: number) {
return 3.0 * aA1;
}

// Returns x(t) given t, x1, and x2, or y(t) given t, y1, and y2.
function calcBezier(aT: number, aA1: number, aA2: number) {
return ((A(aA1, aA2) * aT + B(aA1, aA2)) * aT + C(aA1)) * aT;
}

// Returns dx/dt given t, x1, and x2, or dy/dt given t, y1, and y2.
function getSlope(aT: number, aA1: number, aA2: number) {
return 3.0 * A(aA1, aA2) * aT * aT + 2.0 * B(aA1, aA2) * aT + C(aA1);
}

function binarySubdivide(
aX: number,
_aA: number,
_aB: number,
mX1: number,
mX2: number,
) {
let currentX,
currentT,
i = 0,
aA = _aA,
aB = _aB;
do {
currentT = aA + (aB - aA) / 2.0;
currentX = calcBezier(currentT, mX1, mX2) - aX;
if (currentX > 0.0) {
aB = currentT;
} else {
aA = currentT;
}
} while (
Math.abs(currentX) > SUBDIVISION_PRECISION &&
++i < SUBDIVISION_MAX_ITERATIONS
);
return currentT;
}

function newtonRaphsonIterate(
aX: number,
_aGuessT: number,
mX1: number,
mX2: number,
) {
let aGuessT = _aGuessT;
for (let i = 0; i < NEWTON_ITERATIONS; ++i) {
const currentSlope = getSlope(aGuessT, mX1, mX2);
if (currentSlope === 0.0) {
return aGuessT;
}
const currentX = calcBezier(aGuessT, mX1, mX2) - aX;
aGuessT -= currentX / currentSlope;
// Solves x(t) = ((2a * t + 3b) * t + 3c) * t = x for t, with x in (0, 1):
// u = 1/t is the largest real root of x·u³ − 3c·u² − 3b·u − 2a = 0
function solveTForX(x: number, a: number, b: number, c: number): number {
const j = 1 / Math.max(c, Math.sqrt(x));
const k = x * j;
const l = k * j;
const s = c * j;
const q = b * l;
const m = s * s + q;
const h = -s * (s * s + 1.5 * q) - a * k * l;
const D = h * h - m * m * m;
let v: number;
if (m === 0 || D > 1e-12 * h * h) {
// one real root (Cardano)
const U = -Math.cbrt(h < 0 ? h - Math.sqrt(D) : h + Math.sqrt(D));
v = U + m / U || 0;
} else {
// three real roots, take the largest
const r = Math.sqrt(m);
v =
2 * r * Math.cos(Math.acos(Math.max(-1, Math.min(1, -h / (m * r)))) / 3);
}
return aGuessT;
return Math.min(1, k / (v + s));
}

export default function bezier(
Expand All @@ -102,63 +51,29 @@ export default function bezier(
throw new Error('bezier x values must be in [0, 1] range');
}

// Precompute samples table
const sampleValues = float32ArraySupported
? new Float32Array(kSplineTableSize)
: new Array<number>(kSplineTableSize);
if (mX1 !== mY1 || mX2 !== mY2) {
for (let i = 0; i < kSplineTableSize; ++i) {
sampleValues[i] = calcBezier(i * kSampleStepSize, mX1, mX2);
}
if (mX1 === mY1 && mX2 === mY2) {
return function LinearEasing(x: number): number {
return x;
};
}

function getTForX(aX: number) {
let intervalStart = 0.0;
let currentSample = 1;
const lastSample = kSplineTableSize - 1;

for (
;
currentSample !== lastSample && sampleValues[currentSample] <= aX;
++currentSample
) {
intervalStart += kSampleStepSize;
}
--currentSample;

// Interpolate to provide an initial guess for t
const dist =
(aX - sampleValues[currentSample]) /
(sampleValues[currentSample + 1] - sampleValues[currentSample]);
const guessForT = intervalStart + dist * kSampleStepSize;

const initialSlope = getSlope(guessForT, mX1, mX2);
if (initialSlope >= NEWTON_MIN_SLOPE) {
return newtonRaphsonIterate(aX, guessForT, mX1, mX2);
} else if (initialSlope === 0.0) {
return guessForT;
} else {
return binarySubdivide(
aX,
intervalStart,
intervalStart + kSampleStepSize,
mX1,
mX2,
);
}
}
// x(t) = ((2a * t + 3b) * t + 3c) * t, y(t) = ((ay * t + by) * t + cy) * t
const a = (3 * mX1 - 3 * mX2 + 1) / 2;
const b = mX2 - 2 * mX1;
const c = mX1;
const ay = 3 * mY1 - 3 * mY2 + 1;
const by = 3 * (mY2 - 2 * mY1);
const cy = 3 * mY1;

return function BezierEasing(x: number): number {
if (mX1 === mY1 && mX2 === mY2) {
return x; // linear
}
// Because JavaScript number are imprecise, we should guarantee the extremes are right.
if (x === 0) {
// x outside (0, 1) saturates to 0 / 1
if (x <= 0) {
return 0;
}
if (x === 1) {
if (x >= 1) {
return 1;
}
return calcBezier(getTForX(x), mY1, mY2);
const t = solveTForX(x, a, b, c);
return ((ay * t + by) * t + cy) * t;
};
}
Loading