mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-18 19:21:34 +08:00
fix(animation): add cubic-bezier conversions for gesture animations (#19134)
* enable linear easing switch on progressEnd * Add easing to menu * remove console log * Add tests * clean up code * update comments
This commit is contained in:
@ -3,6 +3,7 @@ import { Build, Component, ComponentInterface, Element, Event, EventEmitter, Hos
|
|||||||
import { config } from '../../global/config';
|
import { config } from '../../global/config';
|
||||||
import { getIonMode } from '../../global/ionic-global';
|
import { getIonMode } from '../../global/ionic-global';
|
||||||
import { Gesture, GestureDetail, IonicAnimation, MenuChangeEventDetail, MenuControllerI, MenuI, Side } from '../../interface';
|
import { Gesture, GestureDetail, IonicAnimation, MenuChangeEventDetail, MenuControllerI, MenuI, Side } from '../../interface';
|
||||||
|
import { Point, getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
|
||||||
import { GESTURE_CONTROLLER } from '../../utils/gesture';
|
import { GESTURE_CONTROLLER } from '../../utils/gesture';
|
||||||
import { assert, isEndSide as isEnd } from '../../utils/helpers';
|
import { assert, isEndSide as isEnd } from '../../utils/helpers';
|
||||||
|
|
||||||
@ -418,11 +419,26 @@ export class Menu implements ComponentInterface, MenuI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.lastOnEnd = detail.timeStamp;
|
this.lastOnEnd = detail.timeStamp;
|
||||||
|
|
||||||
|
// Account for rounding errors in JS
|
||||||
|
let newStepValue = (shouldComplete) ? 0.001 : -0.001;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation will be reversed here, so need to
|
||||||
|
* reverse the easing curve as well
|
||||||
|
*
|
||||||
|
* Additionally, we need to account for the time relative
|
||||||
|
* to the new easing curve, as `stepValue` is going to be given
|
||||||
|
* in terms of a linear curve.
|
||||||
|
*/
|
||||||
|
newStepValue += getTimeGivenProgression(new Point(0, 0), new Point(0.4, 0), new Point(0.6, 1), new Point(1, 1), stepValue);
|
||||||
|
|
||||||
this.animation
|
this.animation
|
||||||
|
.easing('cubic-bezier(0.4, 0.0, 0.6, 1)')
|
||||||
.onFinish(() => this.afterAnimation(shouldOpen), {
|
.onFinish(() => this.afterAnimation(shouldOpen), {
|
||||||
oneTimeCallback: true
|
oneTimeCallback: true
|
||||||
})
|
})
|
||||||
.progressEnd(shouldComplete, stepValue, realDur);
|
.progressEnd(shouldComplete, newStepValue, realDur);
|
||||||
}
|
}
|
||||||
|
|
||||||
private beforeAnimation(shouldOpen: boolean) {
|
private beforeAnimation(shouldOpen: boolean) {
|
||||||
|
@ -3,6 +3,7 @@ import { Build, Component, Element, Event, EventEmitter, Method, Prop, Watch, h
|
|||||||
import { config } from '../../global/config';
|
import { config } from '../../global/config';
|
||||||
import { getIonMode } from '../../global/ionic-global';
|
import { getIonMode } from '../../global/ionic-global';
|
||||||
import { Animation, AnimationBuilder, ComponentProps, FrameworkDelegate, Gesture, IonicAnimation, NavComponent, NavOptions, NavOutlet, NavResult, RouteID, RouteWrite, RouterDirection, TransitionDoneFn, TransitionInstruction, ViewController } from '../../interface';
|
import { Animation, AnimationBuilder, ComponentProps, FrameworkDelegate, Gesture, IonicAnimation, NavComponent, NavOptions, NavOutlet, NavResult, RouteID, RouteWrite, RouterDirection, TransitionDoneFn, TransitionInstruction, ViewController } from '../../interface';
|
||||||
|
import { Point, getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
|
||||||
import { assert } from '../../utils/helpers';
|
import { assert } from '../../utils/helpers';
|
||||||
import { TransitionOptions, lifecycle, setPageHidden, transition } from '../../utils/transition';
|
import { TransitionOptions, lifecycle, setPageHidden, transition } from '../../utils/transition';
|
||||||
|
|
||||||
@ -966,7 +967,26 @@ export class Nav implements NavOutlet {
|
|||||||
this.sbAni.onFinish(() => {
|
this.sbAni.onFinish(() => {
|
||||||
this.animationEnabled = true;
|
this.animationEnabled = true;
|
||||||
}, { oneTimeCallback: true });
|
}, { oneTimeCallback: true });
|
||||||
this.sbAni.progressEnd(shouldComplete, stepValue, dur);
|
|
||||||
|
// Account for rounding errors in JS
|
||||||
|
let newStepValue = (shouldComplete) ? -0.001 : 0.001;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation will be reversed here, so need to
|
||||||
|
* reverse the easing curve as well
|
||||||
|
*
|
||||||
|
* Additionally, we need to account for the time relative
|
||||||
|
* to the new easing curve, as `stepValue` is going to be given
|
||||||
|
* in terms of a linear curve.
|
||||||
|
*/
|
||||||
|
if (!shouldComplete) {
|
||||||
|
this.sbAni.easing('cubic-bezier(1, 0, 0.68, 0.28)');
|
||||||
|
newStepValue += getTimeGivenProgression(new Point(0, 0), new Point(1, 0), new Point(0.68, 0.28), new Point(1, 1), stepValue);
|
||||||
|
} else {
|
||||||
|
newStepValue += getTimeGivenProgression(new Point(0, 0), new Point(0.32, 0.72), new Point(0, 1), new Point(1, 1), stepValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sbAni.progressEnd(shouldComplete, newStepValue, dur);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Method, Pr
|
|||||||
import { config } from '../../global/config';
|
import { config } from '../../global/config';
|
||||||
import { getIonMode } from '../../global/ionic-global';
|
import { getIonMode } from '../../global/ionic-global';
|
||||||
import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Gesture, IonicAnimation, NavOutlet, RouteID, RouteWrite, RouterDirection, RouterOutletOptions, SwipeGestureHandler } from '../../interface';
|
import { Animation, AnimationBuilder, ComponentProps, ComponentRef, FrameworkDelegate, Gesture, IonicAnimation, NavOutlet, RouteID, RouteWrite, RouterDirection, RouterOutletOptions, SwipeGestureHandler } from '../../interface';
|
||||||
|
import { Point, getTimeGivenProgression } from '../../utils/animation/cubic-bezier';
|
||||||
import { attachComponent, detachComponent } from '../../utils/framework-delegate';
|
import { attachComponent, detachComponent } from '../../utils/framework-delegate';
|
||||||
import { transition } from '../../utils/transition';
|
import { transition } from '../../utils/transition';
|
||||||
|
|
||||||
@ -76,7 +77,25 @@ export class RouterOutlet implements ComponentInterface, NavOutlet {
|
|||||||
this.animationEnabled = true;
|
this.animationEnabled = true;
|
||||||
}, { oneTimeCallback: true });
|
}, { oneTimeCallback: true });
|
||||||
|
|
||||||
this.ani.progressEnd(shouldComplete, step, dur);
|
// Account for rounding errors in JS
|
||||||
|
let newStepValue = (shouldComplete) ? -0.001 : 0.001;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation will be reversed here, so need to
|
||||||
|
* reverse the easing curve as well
|
||||||
|
*
|
||||||
|
* Additionally, we need to account for the time relative
|
||||||
|
* to the new easing curve, as `stepValue` is going to be given
|
||||||
|
* in terms of a linear curve.
|
||||||
|
*/
|
||||||
|
if (!shouldComplete) {
|
||||||
|
this.ani.easing('cubic-bezier(1, 0, 0.68, 0.28)');
|
||||||
|
newStepValue += getTimeGivenProgression(new Point(0, 0), new Point(1, 0), new Point(0.68, 0.28), new Point(1, 1), step);
|
||||||
|
} else {
|
||||||
|
newStepValue += getTimeGivenProgression(new Point(0, 0), new Point(0.32, 0.72), new Point(0, 1), new Point(1, 1), step);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ani.progressEnd(shouldComplete, newStepValue, dur);
|
||||||
|
|
||||||
}
|
}
|
||||||
if (this.swipeHandler) {
|
if (this.swipeHandler) {
|
||||||
|
@ -787,6 +787,8 @@ export const createAnimation = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const progressEnd = (shouldComplete: boolean, step: number, dur: number | undefined) => {
|
const progressEnd = (shouldComplete: boolean, step: number, dur: number | undefined) => {
|
||||||
|
shouldForceLinearEasing = false;
|
||||||
|
|
||||||
childAnimations.forEach(animation => {
|
childAnimations.forEach(animation => {
|
||||||
animation.progressEnd(shouldComplete, step, dur);
|
animation.progressEnd(shouldComplete, step, dur);
|
||||||
});
|
});
|
||||||
@ -796,7 +798,7 @@ export const createAnimation = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
finished = false;
|
finished = false;
|
||||||
shouldForceLinearEasing = false;
|
|
||||||
willComplete = shouldComplete;
|
willComplete = shouldComplete;
|
||||||
|
|
||||||
if (!shouldComplete) {
|
if (!shouldComplete) {
|
||||||
|
102
core/src/utils/animation/cubic-bezier.ts
Normal file
102
core/src/utils/animation/cubic-bezier.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Based on:
|
||||||
|
* https://stackoverflow.com/questions/7348009/y-coordinate-for-a-given-x-cubic-bezier
|
||||||
|
* https://math.stackexchange.com/questions/26846/is-there-an-explicit-form-for-cubic-b%C3%A9zier-curves
|
||||||
|
* TODO: Reduce rounding error
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class Point {
|
||||||
|
constructor(public x: number, public y: number) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a cubic-bezier curve, get the x value (time) given
|
||||||
|
* the y value (progression).
|
||||||
|
* Ex: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
* P0: (0, 0)
|
||||||
|
* P1: (0.32, 0.72)
|
||||||
|
* P2: (0, 1)
|
||||||
|
* P3: (1, 1)
|
||||||
|
*/
|
||||||
|
export const getTimeGivenProgression = (p0: Point, p1: Point, p2: Point, p3: Point, progression: number) => {
|
||||||
|
const tValues = solveCubicBezier(p0.y, p1.y, p2.y, p3.y, progression);
|
||||||
|
return solveCubicParametricEquation(p0.x, p1.x, p2.x, p3.x, tValues[0]); // TODO: Add better strategy for dealing with multiple solutions
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solve a cubic equation in one dimension (time)
|
||||||
|
*/
|
||||||
|
const solveCubicParametricEquation = (p0: number, p1: number, p2: number, p3: number, t: number) => {
|
||||||
|
const partA = (3 * p1) * Math.pow(t - 1, 2);
|
||||||
|
const partB = (-3 * p2 * t) + (3 * p2) + (p3 * t);
|
||||||
|
const partC = p0 * Math.pow(t - 1, 3);
|
||||||
|
|
||||||
|
return t * (partA + (t * partB)) - partC;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the `t` value for a cubic bezier using Cardano's formula
|
||||||
|
*/
|
||||||
|
const solveCubicBezier = (p0: number, p1: number, p2: number, p3: number, refPoint: number): number[] => {
|
||||||
|
p0 -= refPoint;
|
||||||
|
p1 -= refPoint;
|
||||||
|
p2 -= refPoint;
|
||||||
|
p3 -= refPoint;
|
||||||
|
|
||||||
|
const roots = solveCubicEquation(
|
||||||
|
p3 - 3 * p2 + 3 * p1 - p0,
|
||||||
|
3 * p2 - 6 * p1 + 3 * p0,
|
||||||
|
3 * p1 - 3 * p0,
|
||||||
|
p0
|
||||||
|
);
|
||||||
|
|
||||||
|
return roots.filter(root => root >= 0 && root <= 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const solveQuadraticEquation = (a: number, b: number, c: number) => {
|
||||||
|
const discriminant = b * b - 4 * a * c;
|
||||||
|
|
||||||
|
if (discriminant < 0) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
(-b + Math.sqrt(discriminant)) / (2 * a),
|
||||||
|
(-b - Math.sqrt(discriminant)) / (2 * a)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const solveCubicEquation = (a: number, b: number, c: number, d: number) => {
|
||||||
|
if (a === 0) { return solveQuadraticEquation(b, c, d); }
|
||||||
|
|
||||||
|
b /= a;
|
||||||
|
c /= a;
|
||||||
|
d /= a;
|
||||||
|
|
||||||
|
const p = (3 * c - b * b) / 3;
|
||||||
|
const q = (2 * b * b * b - 9 * b * c + 27 * d) / 27;
|
||||||
|
|
||||||
|
if (p === 0) {
|
||||||
|
return [Math.pow(-q, 1 / 3)];
|
||||||
|
} else if (q === 0) {
|
||||||
|
return [Math.sqrt(-p), -Math.sqrt(-p)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const discriminant = Math.pow(q / 2, 2) + Math.pow(p / 3, 3);
|
||||||
|
|
||||||
|
if (discriminant === 0) {
|
||||||
|
return [Math.pow(q / 2, 1 / 2) - b / 3];
|
||||||
|
} else if (discriminant > 0) {
|
||||||
|
return [Math.pow(-(q / 2) + Math.sqrt(discriminant), 1 / 3) - Math.pow((q / 2) + Math.sqrt(discriminant), 1 / 3) - b / 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Math.sqrt(Math.pow(-(p / 3), 3));
|
||||||
|
const phi = Math.acos(-(q / (2 * Math.sqrt(Math.pow(-(p / 3), 3)))));
|
||||||
|
const s = 2 * Math.pow(r, 1 / 3);
|
||||||
|
|
||||||
|
return [
|
||||||
|
s * Math.cos(phi / 3) - b / 3,
|
||||||
|
s * Math.cos((phi + 2 * Math.PI) / 3) - b / 3,
|
||||||
|
s * Math.cos((phi + 4 * Math.PI) / 3) - b / 3
|
||||||
|
];
|
||||||
|
};
|
@ -1,4 +1,5 @@
|
|||||||
import { createAnimation } from '../animation';
|
import { createAnimation } from '../animation';
|
||||||
|
import { getTimeGivenProgression, Point } from '../cubic-bezier';
|
||||||
|
|
||||||
describe('Animation Class', () => {
|
describe('Animation Class', () => {
|
||||||
|
|
||||||
@ -243,4 +244,64 @@ describe('Animation Class', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('cubic-bezier conversion', () => {
|
||||||
|
describe('should properly get a time value (x value) given a progression value (y value)', () => {
|
||||||
|
it('cubic-bezier(0.32, 0.72, 0, 1)', () => {
|
||||||
|
const equation = [
|
||||||
|
new Point(0, 0),
|
||||||
|
new Point(0.32, 0.72),
|
||||||
|
new Point(0, 1),
|
||||||
|
new Point(1, 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.5), 0.16);
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.97), 0.56);
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.33), 0.11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cubic-bezier(1, 0, 0.68, 0.28)', () => {
|
||||||
|
const equation = [
|
||||||
|
new Point(0, 0),
|
||||||
|
new Point(1, 0),
|
||||||
|
new Point(0.68, 0.28),
|
||||||
|
new Point(1, 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.08), 0.60);
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.50), 0.84);
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.94), 0.98);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cubic-bezier(0.4, 0, 0.6, 1)', () => {
|
||||||
|
const equation = [
|
||||||
|
new Point(0, 0),
|
||||||
|
new Point(0.4, 0),
|
||||||
|
new Point(0.6, 1),
|
||||||
|
new Point(1, 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.39), 0.43);
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.03), 0.11);
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.89), 0.78);
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cubic-bezier(0, 0, 0.2, 1)', () => {
|
||||||
|
const equation = [
|
||||||
|
new Point(0, 0),
|
||||||
|
new Point(0, 0),
|
||||||
|
new Point(0.2, 1),
|
||||||
|
new Point(1, 1)
|
||||||
|
];
|
||||||
|
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.95), 0.71);
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.1), 0.03);
|
||||||
|
shouldApproximatelyEqual(getTimeGivenProgression(...equation, 0.70), 0.35);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldApproximatelyEqual = (givenValue: number, expectedValue: number): boolean => {
|
||||||
|
expect(Math.abs(expectedValue - givenValue)).toBeLessThanOrEqual(0.01);
|
||||||
|
}
|
@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import { Gesture, GestureDetail, createGesture } from './index';
|
import { Gesture, GestureDetail, createGesture } from './index';
|
||||||
|
|
||||||
export const createSwipeBackGesture = (
|
export const createSwipeBackGesture = (
|
||||||
@ -37,6 +36,7 @@ export const createSwipeBackGesture = (
|
|||||||
const dur = missingDistance / Math.abs(velocity);
|
const dur = missingDistance / Math.abs(velocity);
|
||||||
realDur = Math.min(dur, 300);
|
realDur = Math.min(dur, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEndHandler(shouldComplete, stepValue, realDur);
|
onEndHandler(shouldComplete, stepValue, realDur);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user