mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2026-03-13 10:22:08 +08:00
perf(scroll): efficient scroll events and properties
This commit is contained in:
@@ -1,40 +1,388 @@
|
||||
import { CSS, pointerCoord, nativeRaf, rafFrames, cancelRaf } from '../util/dom';
|
||||
import { Subject } from 'rxjs/Subject';
|
||||
|
||||
import { assert } from './util';
|
||||
import { CSS, nativeRaf, pointerCoord, rafFrames } from './dom';
|
||||
import { DomController } from './dom-controller';
|
||||
import { eventOptions, listenEvent } from './ui-event-manager';
|
||||
|
||||
|
||||
export class ScrollView {
|
||||
private _el: HTMLElement;
|
||||
private _js: boolean = false;
|
||||
private _top: number = 0;
|
||||
private _pos: Array<number>;
|
||||
private _velocity: number;
|
||||
private _max: number;
|
||||
private _rafId: number;
|
||||
private _cb: Function;
|
||||
isPlaying: boolean;
|
||||
isScrolling = false;
|
||||
scrollStart = new Subject<ScrollEvent>();
|
||||
scroll = new Subject<ScrollEvent>();
|
||||
scrollEnd = new Subject<ScrollEvent>();
|
||||
|
||||
constructor(ele: HTMLElement) {
|
||||
this._el = ele;
|
||||
private _el: HTMLElement;
|
||||
private _js: boolean;
|
||||
private _t: number = 0;
|
||||
private _l: number = 0;
|
||||
private _lsn: Function;
|
||||
private _endTmr: Function;
|
||||
|
||||
ev: ScrollEvent = {
|
||||
directionY: ScrollDirection.Down,
|
||||
directionX: null
|
||||
};
|
||||
|
||||
|
||||
constructor(private _dom: DomController) {}
|
||||
|
||||
init(ele: HTMLElement, contentTop: number, contentBottom: number) {
|
||||
if (!this._el) {
|
||||
assert(ele, 'scroll-view, element can not be null');
|
||||
this._el = ele;
|
||||
|
||||
if (this._js) {
|
||||
this.enableJsScroll(contentTop, contentBottom);
|
||||
} else {
|
||||
this.enableNativeScrolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getTop(): number {
|
||||
if (this._js) {
|
||||
return this._top;
|
||||
private enableNativeScrolling() {
|
||||
this._js = false;
|
||||
if (!this._el) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this._top = this._el.scrollTop;
|
||||
console.debug(`ScrollView, enableNativeScrolling`);
|
||||
|
||||
const self = this;
|
||||
const ev = self.ev;
|
||||
const positions: number[] = [];
|
||||
|
||||
function scrollCallback(scrollEvent: UIEvent) {
|
||||
ev.timeStamp = scrollEvent.timeStamp;
|
||||
|
||||
// get the current scrollTop
|
||||
// ******** DOM READ ****************
|
||||
ev.scrollTop = self.getTop();
|
||||
|
||||
// get the current scrollLeft
|
||||
// ******** DOM READ ****************
|
||||
ev.scrollLeft = self.getLeft();
|
||||
|
||||
if (!self.isScrolling) {
|
||||
// currently not scrolling, so this is a scroll start
|
||||
self.isScrolling = true;
|
||||
|
||||
// remember the start positions
|
||||
ev.startY = ev.scrollTop;
|
||||
ev.startX = ev.scrollLeft;
|
||||
|
||||
// new scroll, so do some resets
|
||||
ev.velocityY = ev.velocityX = 0;
|
||||
ev.deltaY = ev.deltaX = 0;
|
||||
positions.length = 0;
|
||||
|
||||
// emit only on the first scroll event
|
||||
self.scrollStart.next(ev);
|
||||
}
|
||||
|
||||
// actively scrolling
|
||||
positions.push(ev.scrollTop, ev.scrollLeft, ev.timeStamp);
|
||||
|
||||
if (positions.length > 3) {
|
||||
// we've gotten at least 2 scroll events so far
|
||||
ev.deltaY = (ev.scrollTop - ev.startY);
|
||||
ev.deltaX = (ev.scrollLeft - ev.startX);
|
||||
|
||||
var endPos = (positions.length - 1);
|
||||
var startPos = endPos;
|
||||
var timeRange = (ev.timeStamp - 100);
|
||||
|
||||
// move pointer to position measured 100ms ago
|
||||
for (var i = endPos; i > 0 && positions[i] > timeRange; i -= 3) {
|
||||
startPos = i;
|
||||
}
|
||||
|
||||
if (startPos !== endPos) {
|
||||
// compute relative movement between these two points
|
||||
var timeOffset = (positions[endPos] - positions[startPos]);
|
||||
var movedTop = (positions[startPos - 2] - positions[endPos - 2]);
|
||||
var movedLeft = (positions[startPos - 1] - positions[endPos - 1]);
|
||||
|
||||
// based on XXms compute the movement to apply for each render step
|
||||
ev.velocityY = ((movedTop / timeOffset) * FRAME_MS);
|
||||
ev.velocityX = ((movedLeft / timeOffset) * FRAME_MS);
|
||||
|
||||
// figure out which direction we're scrolling
|
||||
ev.directionY = (movedTop > 0 ? ScrollDirection.Up : ScrollDirection.Down);
|
||||
ev.directionX = (movedLeft > 0 ? ScrollDirection.Left : ScrollDirection.Right);
|
||||
}
|
||||
}
|
||||
|
||||
// emit on each scroll event
|
||||
self.scroll.next(ev);
|
||||
|
||||
// debounce for a moment after the last scroll event
|
||||
self._endTmr && self._endTmr();
|
||||
self._endTmr = rafFrames(5, function scrollEnd() {
|
||||
// haven't scrolled in a while, so it's a scrollend
|
||||
self.isScrolling = false;
|
||||
|
||||
// reset velocity, do not reset the directions or deltas
|
||||
ev.velocityY = ev.velocityX = 0;
|
||||
|
||||
// emit that the scroll has ended
|
||||
self.scrollEnd.next(ev);
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
// clear out any existing listeners (just to be safe)
|
||||
self._lsn && self._lsn();
|
||||
|
||||
// assign the raw scroll listener
|
||||
// note that it does not have a wrapping requestAnimationFrame on purpose
|
||||
// a scroll event callback will always be right before the raf callback
|
||||
// so there's little to no value of using raf here since it'll all ways immediately
|
||||
// call the raf if it was set within the scroll event, so this will save us some time
|
||||
const opts = eventOptions(false, false);
|
||||
self._lsn = listenEvent(self._el, 'scroll', false, opts, scrollCallback);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @private
|
||||
* JS Scrolling has been provided only as a temporary solution
|
||||
* until iOS apps can take advantage of scroll events at all times.
|
||||
* The goal is to eventually remove JS scrolling entirely. When we
|
||||
* no longer have to worry about iOS not firing scroll events during
|
||||
* inertia then this can be burned to the ground. iOS's more modern
|
||||
* WKWebView does not have this issue, only UIWebView does.
|
||||
*/
|
||||
enableJsScroll(contentTop: number, contentBottom: number) {
|
||||
const self = this;
|
||||
self._js = true;
|
||||
const ele = self._el;
|
||||
|
||||
if (!ele) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug(`ScrollView, enableJsScroll`);
|
||||
|
||||
const positions: number[] = [];
|
||||
let rafCancel: Function;
|
||||
let max: number;
|
||||
|
||||
const ev = self.ev;
|
||||
ev.scrollLeft = 0;
|
||||
ev.startX = 0;
|
||||
ev.deltaX = 0;
|
||||
ev.velocityX = 0;
|
||||
ev.directionX = null;
|
||||
|
||||
function setMax() {
|
||||
if (!max) {
|
||||
// ******** DOM READ ****************
|
||||
max = (ele.scrollHeight - ele.offsetHeight) + contentTop + contentBottom;
|
||||
}
|
||||
};
|
||||
|
||||
function jsScrollDecelerate(timeStamp: number) {
|
||||
ev.timeStamp = timeStamp;
|
||||
|
||||
console.debug(`scroll-view, decelerate, velocity: ${ev.velocityY}`);
|
||||
|
||||
if (ev.velocityY) {
|
||||
ev.velocityY *= DECELERATION_FRICTION;
|
||||
|
||||
// update top with updated velocity
|
||||
// clamp top within scroll limits
|
||||
setMax();
|
||||
self._t = Math.min(Math.max(self._t + ev.velocityY, 0), max);
|
||||
|
||||
ev.scrollTop = self._t;
|
||||
|
||||
// emit on each scroll event
|
||||
self.scroll.next(ev);
|
||||
|
||||
self._dom.write(() => {
|
||||
// ******** DOM WRITE ****************
|
||||
self.setTop(self._t);
|
||||
|
||||
if (self._t > 0 && self._t < max && Math.abs(ev.velocityY) > MIN_VELOCITY_CONTINUE_DECELERATION) {
|
||||
rafCancel = self._dom.read(rafTimeStamp => {
|
||||
jsScrollDecelerate(rafTimeStamp);
|
||||
});
|
||||
|
||||
} else {
|
||||
// haven't scrolled in a while, so it's a scrollend
|
||||
self.isScrolling = false;
|
||||
|
||||
// reset velocity, do not reset the directions or deltas
|
||||
ev.velocityY = ev.velocityX = 0;
|
||||
|
||||
// emit that the scroll has ended
|
||||
self.scrollEnd.next(ev);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function jsScrollTouchStart(touchEvent: TouchEvent) {
|
||||
positions.length = 0;
|
||||
max = null;
|
||||
positions.push(pointerCoord(touchEvent).y, touchEvent.timeStamp);
|
||||
}
|
||||
|
||||
function jsScrollTouchMove(touchEvent: TouchEvent) {
|
||||
if (!positions.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.timeStamp = touchEvent.timeStamp;
|
||||
|
||||
var y = pointerCoord(touchEvent).y;
|
||||
|
||||
// ******** DOM READ ****************
|
||||
setMax();
|
||||
self._t -= (y - positions[positions.length - 2]);
|
||||
self._t = Math.min(Math.max(self._t, 0), max);
|
||||
|
||||
positions.push(y, ev.timeStamp);
|
||||
|
||||
if (!self.isScrolling) {
|
||||
// remember the start position
|
||||
ev.startY = self._t;
|
||||
|
||||
// new scroll, so do some resets
|
||||
ev.velocityY = ev.deltaY = 0;
|
||||
|
||||
self.isScrolling = true;
|
||||
|
||||
// emit only on the first scroll event
|
||||
self.scrollStart.next(ev);
|
||||
}
|
||||
|
||||
self._dom.write(() => {
|
||||
// ******** DOM WRITE ****************
|
||||
self.setTop(self._t);
|
||||
});
|
||||
}
|
||||
|
||||
function jsScrollTouchEnd(touchEvent: TouchEvent) {
|
||||
// figure out what the scroll position was about 100ms ago
|
||||
self._dom.cancel(rafCancel);
|
||||
|
||||
if (!positions.length && self.isScrolling) {
|
||||
self.isScrolling = false;
|
||||
ev.velocityY = ev.velocityX = 0;
|
||||
self.scrollEnd.next(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
var y = pointerCoord(touchEvent).y;
|
||||
|
||||
positions.push(y, touchEvent.timeStamp);
|
||||
|
||||
var endPos = (positions.length - 1);
|
||||
var startPos = endPos;
|
||||
var timeRange = (touchEvent.timeStamp - 100);
|
||||
|
||||
// move pointer to position measured 100ms ago
|
||||
for (var i = endPos; i > 0 && positions[i] > timeRange; i -= 2) {
|
||||
startPos = i;
|
||||
}
|
||||
|
||||
if (startPos !== endPos) {
|
||||
// compute relative movement between these two points
|
||||
var timeOffset = (positions[endPos] - positions[startPos]);
|
||||
var movedTop = (positions[startPos - 1] - positions[endPos - 1]);
|
||||
|
||||
// based on XXms compute the movement to apply for each render step
|
||||
ev.velocityY = ((movedTop / timeOffset) * FRAME_MS);
|
||||
|
||||
// verify that we have enough velocity to start deceleration
|
||||
if (Math.abs(ev.velocityY) > MIN_VELOCITY_START_DECELERATION) {
|
||||
// ******** DOM READ ****************
|
||||
setMax();
|
||||
|
||||
rafCancel = self._dom.read((rafTimeStamp: number) => {
|
||||
jsScrollDecelerate(rafTimeStamp);
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
self.isScrolling = false;
|
||||
ev.velocityY = ev.velocityX = 0;
|
||||
self.scrollEnd.next(ev);
|
||||
}
|
||||
|
||||
positions.length = 0;
|
||||
}
|
||||
|
||||
const opts = eventOptions(false, true);
|
||||
const unRegStart = listenEvent(ele, 'touchstart', false, opts, jsScrollTouchStart);
|
||||
const unRegMove = listenEvent(ele, 'touchmove', false, opts, jsScrollTouchMove);
|
||||
const unRegEnd = listenEvent(ele, 'touchend', false, opts, jsScrollTouchEnd);
|
||||
|
||||
ele.parentElement.classList.add('js-scroll');
|
||||
|
||||
// stop listening for actual scroll events
|
||||
self._lsn && self._lsn();
|
||||
|
||||
// create an unregister for all of these events
|
||||
self._lsn = () => {
|
||||
unRegStart();
|
||||
unRegMove();
|
||||
unRegEnd();
|
||||
ele.parentElement.classList.remove('js-scroll');
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* DOM READ
|
||||
*/
|
||||
getTop() {
|
||||
if (this._js) {
|
||||
return this._t;
|
||||
}
|
||||
return this._t = this._el.scrollTop || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM READ
|
||||
*/
|
||||
getLeft() {
|
||||
if (this._js) {
|
||||
return 0;
|
||||
}
|
||||
return this._l = this._el.scrollLeft || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM WRITE
|
||||
*/
|
||||
setTop(top: number) {
|
||||
this._top = top;
|
||||
this._t = top;
|
||||
|
||||
if (this._js) {
|
||||
(<any>this._el.style)[CSS.transform] = `translate3d(0px,${top * -1}px,0px)`;
|
||||
(<any>this._el.style)[CSS.transform] = `translate3d(${this._l * -1}px,${top * -1}px,0px)`;
|
||||
|
||||
} else {
|
||||
this._el.scrollTop = top;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DOM WRITE
|
||||
*/
|
||||
setLeft(left: number) {
|
||||
this._l = left;
|
||||
|
||||
if (this._js) {
|
||||
(<any>this._el.style)[CSS.transform] = `translate3d(${left * -1}px,${this._t * -1}px,0px)`;
|
||||
|
||||
} else {
|
||||
this._el.scrollLeft = left;
|
||||
}
|
||||
}
|
||||
|
||||
scrollTo(x: number, y: number, duration: number, done?: Function): Promise<any> {
|
||||
// scroll animation loop w/ easing
|
||||
// credit https://gist.github.com/dezinezync/5487119
|
||||
@@ -68,17 +416,17 @@ export class ScrollView {
|
||||
let attempts = 0;
|
||||
|
||||
// scroll loop
|
||||
function step() {
|
||||
function step(timeStamp: number) {
|
||||
attempts++;
|
||||
|
||||
if (!self._el || !self.isPlaying || attempts > maxAttempts) {
|
||||
self.isPlaying = false;
|
||||
if (!self._el || !self.isScrolling || attempts > maxAttempts) {
|
||||
self.isScrolling = false;
|
||||
self._el.style.transform = ``;
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
let time = Math.min(1, ((Date.now() - startTime) / duration));
|
||||
let time = Math.min(1, ((timeStamp - startTime) / duration));
|
||||
|
||||
// where .5 would be 50% of time on a linear scale easedT gives a
|
||||
// fraction based on the easing method
|
||||
@@ -89,25 +437,28 @@ export class ScrollView {
|
||||
}
|
||||
|
||||
if (fromX !== x) {
|
||||
self._el.scrollLeft = Math.floor((easedT * (x - fromX)) + fromX);
|
||||
self.setLeft(Math.floor((easedT * (x - fromX)) + fromX));
|
||||
}
|
||||
|
||||
if (easedT < 1) {
|
||||
// do not use DomController here
|
||||
// must use nativeRaf in order to fire in the next frame
|
||||
nativeRaf(step);
|
||||
|
||||
} else {
|
||||
self.isScrolling = false;
|
||||
self._el.style.transform = ``;
|
||||
done();
|
||||
}
|
||||
}
|
||||
|
||||
// start scroll loop
|
||||
self.isPlaying = true;
|
||||
self.isScrolling = true;
|
||||
|
||||
// chill out for a frame first
|
||||
rafFrames(2, () => {
|
||||
startTime = Date.now();
|
||||
step();
|
||||
rafFrames(2, (timeStamp) => {
|
||||
startTime = timeStamp;
|
||||
step(timeStamp);
|
||||
});
|
||||
|
||||
return promise;
|
||||
@@ -126,167 +477,51 @@ export class ScrollView {
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isPlaying = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* JS Scrolling has been provided only as a temporary solution
|
||||
* until iOS apps can take advantage of scroll events at all times.
|
||||
* The goal is to eventually remove JS scrolling entirely. This
|
||||
* method may be removed in the future.
|
||||
*/
|
||||
jsScroll(onScrollCallback: Function): Function {
|
||||
this._js = true;
|
||||
this._cb = onScrollCallback;
|
||||
this._pos = [];
|
||||
|
||||
if (this._el) {
|
||||
this._el.addEventListener('touchstart', this._start.bind(this));
|
||||
this._el.addEventListener('touchmove', this._move.bind(this));
|
||||
this._el.addEventListener('touchend', this._end.bind(this));
|
||||
this._el.parentElement.classList.add('js-scroll');
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (this._el) {
|
||||
this._el.removeEventListener('touchstart', this._start.bind(this));
|
||||
this._el.removeEventListener('touchmove', this._move.bind(this));
|
||||
this._el.removeEventListener('touchend', this._end.bind(this));
|
||||
this._el.parentElement.classList.remove('js-scroll');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Used for JS scrolling. May be removed in the future.
|
||||
*/
|
||||
private _start(ev: UIEvent) {
|
||||
this._velocity = 0;
|
||||
this._pos.length = 0;
|
||||
this._max = null;
|
||||
this._pos.push(pointerCoord(ev).y, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Used for JS scrolling. May be removed in the future.
|
||||
*/
|
||||
private _move(ev: UIEvent) {
|
||||
if (this._pos.length) {
|
||||
let y = pointerCoord(ev).y;
|
||||
|
||||
// ******** DOM READ ****************
|
||||
this._setMax();
|
||||
|
||||
this._top -= (y - this._pos[this._pos.length - 2]);
|
||||
|
||||
this._top = Math.min(Math.max(this._top, 0), this._max);
|
||||
|
||||
this._pos.push(y, Date.now());
|
||||
|
||||
// ******** DOM READ THEN DOM WRITE ****************
|
||||
this._cb(this._top);
|
||||
|
||||
// ******** DOM WRITE ****************
|
||||
this.setTop(this._top);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Used for JS scrolling. May be removed in the future.
|
||||
*/
|
||||
private _setMax() {
|
||||
if (!this._max) {
|
||||
// ******** DOM READ ****************
|
||||
this._max = (this._el.offsetHeight - this._el.parentElement.offsetHeight + this._el.parentElement.offsetTop);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Used for JS scrolling. May be removed in the future.
|
||||
*/
|
||||
private _end(ev: UIEvent) {
|
||||
// figure out what the scroll position was about 100ms ago
|
||||
let positions = this._pos;
|
||||
this._velocity = 0;
|
||||
cancelRaf(this._rafId);
|
||||
|
||||
if (!positions.length) return;
|
||||
|
||||
let y = pointerCoord(ev).y;
|
||||
|
||||
positions.push(y, Date.now());
|
||||
|
||||
let endPos = (positions.length - 1);
|
||||
let startPos = endPos;
|
||||
let timeRange = (Date.now() - 100);
|
||||
|
||||
// move pointer to position measured 100ms ago
|
||||
for (var i = endPos; i > 0 && positions[i] > timeRange; i -= 2) {
|
||||
startPos = i;
|
||||
}
|
||||
|
||||
if (startPos !== endPos) {
|
||||
// compute relative movement between these two points
|
||||
let timeOffset = (positions[endPos] - positions[startPos]);
|
||||
let movedTop = (positions[startPos - 1] - positions[endPos - 1]);
|
||||
|
||||
// based on XXms compute the movement to apply for each render step
|
||||
this._velocity = ((movedTop / timeOffset) * FRAME_MS);
|
||||
|
||||
// verify that we have enough velocity to start deceleration
|
||||
if (Math.abs(this._velocity) > MIN_VELOCITY_START_DECELERATION) {
|
||||
// ******** DOM READ ****************
|
||||
this._setMax();
|
||||
|
||||
this._rafId = nativeRaf(this._decelerate.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
positions.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Used for JS scrolling. May be removed in the future.
|
||||
*/
|
||||
private _decelerate() {
|
||||
var self = this;
|
||||
|
||||
if (self._velocity) {
|
||||
self._velocity *= DECELERATION_FRICTION;
|
||||
|
||||
// update top with updated velocity
|
||||
// clamp top within scroll limits
|
||||
self._top = Math.min(Math.max(self._top + self._velocity, 0), self._max);
|
||||
|
||||
// ******** DOM READ THEN DOM WRITE ****************
|
||||
self._cb(self._top);
|
||||
|
||||
// ******** DOM WRITE ****************
|
||||
self.setTop(self._top);
|
||||
|
||||
if (self._top > 0 && self._top < self._max && Math.abs(self._velocity) > MIN_VELOCITY_CONTINUE_DECELERATION) {
|
||||
self._rafId = nativeRaf(self._decelerate.bind(self));
|
||||
}
|
||||
}
|
||||
this.isScrolling = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
destroy() {
|
||||
this._velocity = 0;
|
||||
this.scrollStart.unsubscribe();
|
||||
this.scroll.unsubscribe();
|
||||
this.scrollEnd.unsubscribe();
|
||||
|
||||
this.stop();
|
||||
this._el = null;
|
||||
this._lsn();
|
||||
this._endTmr && this._endTmr();
|
||||
this._el = this._dom = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
export interface ScrollEvent {
|
||||
scrollTop?: number;
|
||||
scrollLeft?: number;
|
||||
startY?: number;
|
||||
startX?: number;
|
||||
deltaY?: number;
|
||||
deltaX?: number;
|
||||
timeStamp?: number;
|
||||
velocityY?: number;
|
||||
velocityX?: number;
|
||||
directionY?: ScrollDirection;
|
||||
directionX?: ScrollDirection;
|
||||
}
|
||||
|
||||
|
||||
export enum ScrollDirection {
|
||||
Up, Down, Left, Right
|
||||
}
|
||||
|
||||
|
||||
export interface DomFn {
|
||||
(callback: Function): void;
|
||||
}
|
||||
|
||||
|
||||
const MIN_VELOCITY_START_DECELERATION = 4;
|
||||
const MIN_VELOCITY_CONTINUE_DECELERATION = 0.12;
|
||||
const DECELERATION_FRICTION = 0.97;
|
||||
|
||||
Reference in New Issue
Block a user