diff --git a/core/src/components/menu/menu.tsx b/core/src/components/menu/menu.tsx index 27f102eef3..a5127ed4a6 100644 --- a/core/src/components/menu/menu.tsx +++ b/core/src/components/menu/menu.tsx @@ -3,6 +3,7 @@ import { Build, Component, ComponentInterface, Element, Event, EventEmitter, Hos import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; 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 { assert, isEndSide as isEnd } from '../../utils/helpers'; @@ -418,11 +419,26 @@ export class Menu implements ComponentInterface, MenuI { } 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 + .easing('cubic-bezier(0.4, 0.0, 0.6, 1)') .onFinish(() => this.afterAnimation(shouldOpen), { oneTimeCallback: true }) - .progressEnd(shouldComplete, stepValue, realDur); + .progressEnd(shouldComplete, newStepValue, realDur); } private beforeAnimation(shouldOpen: boolean) { diff --git a/core/src/components/nav/nav.tsx b/core/src/components/nav/nav.tsx index 07fc883a8b..c1b1b08a52 100644 --- a/core/src/components/nav/nav.tsx +++ b/core/src/components/nav/nav.tsx @@ -3,6 +3,7 @@ import { Build, Component, Element, Event, EventEmitter, Method, Prop, Watch, h import { config } from '../../global/config'; 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 { Point, getTimeGivenProgression } from '../../utils/animation/cubic-bezier'; import { assert } from '../../utils/helpers'; import { TransitionOptions, lifecycle, setPageHidden, transition } from '../../utils/transition'; @@ -966,7 +967,26 @@ export class Nav implements NavOutlet { this.sbAni.onFinish(() => { this.animationEnabled = 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); } } diff --git a/core/src/components/router-outlet/route-outlet.tsx b/core/src/components/router-outlet/route-outlet.tsx index 6c2f244bee..e94586ef95 100644 --- a/core/src/components/router-outlet/route-outlet.tsx +++ b/core/src/components/router-outlet/route-outlet.tsx @@ -3,6 +3,7 @@ import { Component, ComponentInterface, Element, Event, EventEmitter, Method, Pr import { config } from '../../global/config'; import { getIonMode } from '../../global/ionic-global'; 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 { transition } from '../../utils/transition'; @@ -76,7 +77,25 @@ export class RouterOutlet implements ComponentInterface, NavOutlet { this.animationEnabled = 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) { diff --git a/core/src/utils/animation/animation.ts b/core/src/utils/animation/animation.ts index 3d0a3cd344..bf91bad824 100644 --- a/core/src/utils/animation/animation.ts +++ b/core/src/utils/animation/animation.ts @@ -787,6 +787,8 @@ export const createAnimation = () => { }; const progressEnd = (shouldComplete: boolean, step: number, dur: number | undefined) => { + shouldForceLinearEasing = false; + childAnimations.forEach(animation => { animation.progressEnd(shouldComplete, step, dur); }); @@ -796,7 +798,7 @@ export const createAnimation = () => { } finished = false; - shouldForceLinearEasing = false; + willComplete = shouldComplete; if (!shouldComplete) { diff --git a/core/src/utils/animation/cubic-bezier.ts b/core/src/utils/animation/cubic-bezier.ts new file mode 100644 index 0000000000..85d288406c --- /dev/null +++ b/core/src/utils/animation/cubic-bezier.ts @@ -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 + ]; +}; diff --git a/core/src/utils/animation/test/animation.spec.ts b/core/src/utils/animation/test/animation.spec.ts index 83409dcf31..b10d432252 100644 --- a/core/src/utils/animation/test/animation.spec.ts +++ b/core/src/utils/animation/test/animation.spec.ts @@ -1,4 +1,5 @@ import { createAnimation } from '../animation'; +import { getTimeGivenProgression, Point } from '../cubic-bezier'; describe('Animation Class', () => { @@ -243,4 +244,64 @@ describe('Animation Class', () => { }); }) -}); \ No newline at end of file +}); + +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); +} \ No newline at end of file diff --git a/core/src/utils/gesture/swipe-back.ts b/core/src/utils/gesture/swipe-back.ts index a0f3e76074..043be85537 100644 --- a/core/src/utils/gesture/swipe-back.ts +++ b/core/src/utils/gesture/swipe-back.ts @@ -1,4 +1,3 @@ - import { Gesture, GestureDetail, createGesture } from './index'; export const createSwipeBackGesture = ( @@ -37,6 +36,7 @@ export const createSwipeBackGesture = ( const dur = missingDistance / Math.abs(velocity); realDur = Math.min(dur, 300); } + onEndHandler(shouldComplete, stepValue, realDur); };