igniteui-angular-sovn
Version:
Ignite UI for Angular is a dependency-free Angular toolkit for building modern web apps
461 lines (397 loc) • 17.6 kB
text/typescript
import { Directive, Input, ElementRef, NgZone, OnInit, OnDestroy } from '@angular/core';
/**
* @hidden
*/
export class IgxScrollInertiaDirective implements OnInit, OnDestroy {
public IgxScrollInertiaDirection: string;
public IgxScrollInertiaScrollContainer: any;
public wheelStep = 50;
public inertiaStep = 1.5;
public smoothingStep = 1.5;
public smoothingDuration = 0.5;
public swipeToleranceX = 20;
public inertiaDeltaY = 3;
public inertiaDeltaX = 2;
public inertiaDuration = 0.5;
private _touchInertiaAnimID;
private _startX;
private _startY;
private _touchStartX;
private _touchStartY;
private _lastTouchEnd;
private _lastTouchX;
private _lastTouchY;
private _savedSpeedsX = [];
private _savedSpeedsY;
private _totalMovedX;
private _offsetRecorded;
private _offsetDirection;
private _lastMovedX;
private _lastMovedY;
private _nextX;
private _nextY;
private parentElement;
private baseDeltaMultiplier = 1 / 120;
private firefoxDeltaMultiplier = 1 / 30;
constructor(private element: ElementRef, private _zone: NgZone) { }
public ngOnInit(): void {
this._zone.runOutsideAngular(() => {
this.parentElement = this.element.nativeElement.parentElement || this.element.nativeElement.parentNode;
if (!this.parentElement) {
return;
}
const targetElem = this.parentElement;
targetElem.addEventListener('wheel', this.onWheel.bind(this));
targetElem.addEventListener('touchstart', this.onTouchStart.bind(this));
targetElem.addEventListener('touchmove', this.onTouchMove.bind(this));
targetElem.addEventListener('touchend', this.onTouchEnd.bind(this));
});
}
public ngOnDestroy() {
this._zone.runOutsideAngular(() => {
const targetElem = this.parentElement;
if (!targetElem) {
return;
}
targetElem.removeEventListener('wheel', this.onWheel);
targetElem.removeEventListener('touchstart', this.onTouchStart);
targetElem.removeEventListener('touchmove', this.onTouchMove);
targetElem.removeEventListener('touchend', this.onTouchEnd);
});
}
/**
* @hidden
* Function that is called when scrolling with the mouse wheel or using touchpad
*/
protected onWheel(evt) {
// if no scrollbar return
if (!this.IgxScrollInertiaScrollContainer) {
return;
}
// if ctrl key is pressed and the user want to zoom in/out the page
if (evt.ctrlKey) {
return;
}
let scrollDeltaX;
let scrollDeltaY;
const scrollStep = this.wheelStep;
const minWheelStep = 1 / this.wheelStep;
const smoothing = this.smoothingDuration !== 0;
this._startX = this.IgxScrollInertiaScrollContainer.scrollLeft;
this._startY = this.IgxScrollInertiaScrollContainer.scrollTop;
if (evt.wheelDeltaX) {
/* Option supported on Chrome, Safari, Opera.
/* 120 is default for mousewheel on these browsers. Other values are for trackpads */
scrollDeltaX = -evt.wheelDeltaX * this.baseDeltaMultiplier;
if (-minWheelStep < scrollDeltaX && scrollDeltaX < minWheelStep) {
scrollDeltaX = Math.sign(scrollDeltaX) * minWheelStep;
}
} else if (evt.deltaX) {
/* For other browsers that don't provide wheelDelta, use the deltaY to determine direction and pass default values. */
const deltaScaledX = evt.deltaX * (evt.deltaMode === 0 ? this.firefoxDeltaMultiplier : 1);
scrollDeltaX = this.calcAxisCoords(deltaScaledX, -1, 1);
}
/** Get delta for the Y axis */
if (evt.wheelDeltaY) {
/* Option supported on Chrome, Safari, Opera.
/* 120 is default for mousewheel on these browsers. Other values are for trackpads */
scrollDeltaY = -evt.wheelDeltaY * this.baseDeltaMultiplier;
if (-minWheelStep < scrollDeltaY && scrollDeltaY < minWheelStep) {
scrollDeltaY = Math.sign(scrollDeltaY) * minWheelStep;
}
} else if (evt.deltaY) {
/* For other browsers that don't provide wheelDelta, use the deltaY to determine direction and pass default values. */
const deltaScaledY = evt.deltaY * (evt.deltaMode === 0 ? this.firefoxDeltaMultiplier : 1);
scrollDeltaY = this.calcAxisCoords(deltaScaledY, -1, 1);
}
if (evt.composedPath && this.didChildScroll(evt, scrollDeltaX, scrollDeltaY)) {
return;
}
if (scrollDeltaX && this.IgxScrollInertiaDirection === 'horizontal') {
const nextLeft = this._startX + scrollDeltaX * scrollStep;
if (!smoothing) {
this._scrollToX(nextLeft);
} else {
this._smoothWheelScroll(scrollDeltaX);
}
const maxScrollLeft = parseInt(this.IgxScrollInertiaScrollContainer.children[0].style.width, 10);
if (0 < nextLeft && nextLeft < maxScrollLeft) {
// Prevent navigating through pages when scrolling on Mac
evt.preventDefault();
}
} else if (evt.shiftKey && scrollDeltaY && this.IgxScrollInertiaDirection === 'horizontal') {
if (!smoothing) {
const step = this._startX + scrollDeltaY * scrollStep;
this._scrollToX(step);
} else {
this._smoothWheelScroll(scrollDeltaY);
}
} else if (!evt.shiftKey && scrollDeltaY && this.IgxScrollInertiaDirection === 'vertical') {
const nextTop = this._startY + scrollDeltaY * scrollStep;
if (!smoothing) {
this._scrollToY(nextTop);
} else {
this._smoothWheelScroll(scrollDeltaY);
}
this.preventParentScroll(evt, true, nextTop);
}
}
/**
* @hidden
* When there is still room to scroll up/down prevent the parent elements from scrolling too.
*/
protected preventParentScroll(evt, preventDefault, nextTop = 0) {
const curScrollTop = nextTop === 0 ? this.IgxScrollInertiaScrollContainer.scrollTop : nextTop;
const maxScrollTop = this.IgxScrollInertiaScrollContainer.children[0].scrollHeight -
this.IgxScrollInertiaScrollContainer.offsetHeight;
if (0 < curScrollTop && curScrollTop < maxScrollTop) {
if (preventDefault) {
evt.preventDefault();
}
if (evt.stopPropagation) {
evt.stopPropagation();
}
}
}
/**
* @hidden
* Checks if the wheel event would have scrolled an element under the display container
* in DOM tree so that it can correctly be ignored until that element can no longer be scrolled.
*/
protected didChildScroll(evt, scrollDeltaX, scrollDeltaY): boolean {
const path = evt.composedPath();
let i = 0;
while (i < path.length && path[i].localName !== 'igx-display-container') {
const e = path[i++];
if (e.scrollHeight > e.clientHeight) {
const overflowY = window.getComputedStyle(e)['overflow-y'];
if (overflowY === 'auto' || overflowY === 'scroll') {
if (scrollDeltaY > 0 && e.scrollHeight - Math.abs(Math.round(e.scrollTop)) !== e.clientHeight) {
return true;
}
if (scrollDeltaY < 0 && e.scrollTop !== 0) {
return true;
}
}
}
if (e.scrollWidth > e.clientWidth) {
const overflowX = window.getComputedStyle(e)['overflow-x'];
if (overflowX === 'auto' || overflowX === 'scroll') {
if (scrollDeltaX > 0 && e.scrollWidth - Math.abs(Math.round(e.scrollLeft)) !== e.clientWidth) {
return true;
}
if (scrollDeltaX < 0 && e.scrollLeft !== 0) {
return true;
}
}
}
}
return false;
}
/**
* @hidden
* Function that is called the first moment we start interacting with the content on a touch device
*/
protected onTouchStart(event) {
if (!this.IgxScrollInertiaScrollContainer) {
return false;
}
// stops any current ongoing inertia
cancelAnimationFrame(this._touchInertiaAnimID);
const touch = event.touches[0];
this._startX = this.IgxScrollInertiaScrollContainer.scrollLeft;
this._startY = this.IgxScrollInertiaScrollContainer.scrollTop;
this._touchStartX = touch.pageX;
this._touchStartY = touch.pageY;
this._lastTouchEnd = new Date().getTime();
this._lastTouchX = touch.pageX;
this._lastTouchY = touch.pageY;
this._savedSpeedsX = [];
this._savedSpeedsY = [];
// Vars regarding swipe offset
this._totalMovedX = 0;
this._offsetRecorded = false;
this._offsetDirection = 0;
if (this.IgxScrollInertiaDirection === 'vertical') {
this.preventParentScroll(event, false);
}
}
/**
* @hidden
* Function that is called when we need to scroll the content based on touch interactions
*/
protected onTouchMove(event) {
if (!this.IgxScrollInertiaScrollContainer) {
return;
}
const touch = event.touches[0];
const destX = this._startX + (this._touchStartX - touch.pageX) * Math.sign(this.inertiaStep);
const destY = this._startY + (this._touchStartY - touch.pageY) * Math.sign(this.inertiaStep);
/* Handle complex touchmoves when swipe stops but the toch doesn't end and then a swipe is initiated again */
/* **********************************************************/
const timeFromLastTouch = (new Date().getTime()) - this._lastTouchEnd;
if (timeFromLastTouch !== 0 && timeFromLastTouch < 100) {
const speedX = (this._lastTouchX - touch.pageX) / timeFromLastTouch;
const speedY = (this._lastTouchY - touch.pageY) / timeFromLastTouch;
// Save the last 5 speeds between two touchmoves on X axis
if (this._savedSpeedsX.length < 5) {
this._savedSpeedsX.push(speedX);
} else {
this._savedSpeedsX.shift();
this._savedSpeedsX.push(speedX);
}
// Save the last 5 speeds between two touchmoves on Y axis
if (this._savedSpeedsY.length < 5) {
this._savedSpeedsY.push(speedY);
} else {
this._savedSpeedsY.shift();
this._savedSpeedsY.push(speedY);
}
}
this._lastTouchEnd = new Date().getTime();
this._lastMovedX = this._lastTouchX - touch.pageX;
this._lastMovedY = this._lastTouchY - touch.pageY;
this._lastTouchX = touch.pageX;
this._lastTouchY = touch.pageY;
this._totalMovedX += this._lastMovedX;
/* Do not scroll using touch untill out of the swipeToleranceX bounds */
if (Math.abs(this._totalMovedX) < this.swipeToleranceX && !this._offsetRecorded) {
this._scrollTo(this._startX, destY);
} else {
/* Record the direction the first time we are out of the swipeToleranceX bounds.
* That way we know which direction we apply the offset so it doesn't hickup when moving out of the swipeToleranceX bounds */
if (!this._offsetRecorded) {
this._offsetDirection = Math.sign(destX - this._startX);
this._offsetRecorded = true;
}
/* Scroll with offset ammout of swipeToleranceX in the direction we have exited the bounds and
don't change it after that ever until touchend and again touchstart */
this._scrollTo(destX - this._offsetDirection * this.swipeToleranceX, destY);
}
// On Safari preventing the touchmove would prevent default page scroll behaviour even if there is the element doesn't have overflow
if (this.IgxScrollInertiaDirection === 'vertical') {
this.preventParentScroll(event, true);
}
}
protected onTouchEnd(event) {
let speedX = 0;
let speedY = 0;
// savedSpeedsX and savedSpeedsY have same length
for (let i = 0; i < this._savedSpeedsX.length; i++) {
speedX += this._savedSpeedsX[i];
speedY += this._savedSpeedsY[i];
}
speedX = this._savedSpeedsX.length ? speedX / this._savedSpeedsX.length : 0;
speedY = this._savedSpeedsX.length ? speedY / this._savedSpeedsY.length : 0;
// Use the lastMovedX and lastMovedY to determine if the swipe stops without lifting the finger so we don't start inertia
if ((Math.abs(speedX) > 0.1 || Math.abs(speedY) > 0.1) &&
(Math.abs(this._lastMovedX) > 2 || Math.abs(this._lastMovedY) > 2)) {
this._inertiaInit(speedX, speedY);
}
if (this.IgxScrollInertiaDirection === 'vertical') {
this.preventParentScroll(event, false);
}
}
protected _smoothWheelScroll(delta) {
this._nextY = this.IgxScrollInertiaScrollContainer.scrollTop;
this._nextX = this.IgxScrollInertiaScrollContainer.scrollLeft;
let x = -1;
let wheelInertialAnimation = null;
const inertiaWheelStep = () => {
if (x > 1) {
cancelAnimationFrame(wheelInertialAnimation);
return;
}
const nextScroll = ((-3 * x * x + 3) * delta * 2) * this.smoothingStep;
if (this.IgxScrollInertiaDirection === 'vertical') {
this._nextY += nextScroll;
this._scrollToY(this._nextY);
} else {
this._nextX += nextScroll;
this._scrollToX(this._nextX);
}
//continue the inertia
x += 0.08 * (1 / this.smoothingDuration);
wheelInertialAnimation = requestAnimationFrame(inertiaWheelStep);
};
wheelInertialAnimation = requestAnimationFrame(inertiaWheelStep);
}
protected _inertiaInit(speedX, speedY) {
const stepModifer = this.inertiaStep;
const inertiaDuration = this.inertiaDuration;
let x = 0;
this._nextX = this.IgxScrollInertiaScrollContainer.scrollLeft;
this._nextY = this.IgxScrollInertiaScrollContainer.scrollTop;
// Sets timeout until executing next movement iteration of the inertia
const inertiaStep = () => {
if (x > 6) {
cancelAnimationFrame(this._touchInertiaAnimID);
return;
}
if (Math.abs(speedX) > Math.abs(speedY)) {
x += 0.05 / (1 * inertiaDuration);
} else {
x += 0.05 / (1 * inertiaDuration);
}
if (x <= 1) {
// We use constant quation to determine the offset without speed falloff befor x reaches 1
if (Math.abs(speedY) <= Math.abs(speedX) * this.inertiaDeltaY) {
this._nextX += 1 * speedX * 15 * stepModifer;
}
if (Math.abs(speedY) >= Math.abs(speedX) * this.inertiaDeltaX) {
this._nextY += 1 * speedY * 15 * stepModifer;
}
} else {
// We use the quation "y = 2 / (x + 0.55) - 0.3" to determine the offset
if (Math.abs(speedY) <= Math.abs(speedX) * this.inertiaDeltaY) {
this._nextX += Math.abs(2 / (x + 0.55) - 0.3) * speedX * 15 * stepModifer;
}
if (Math.abs(speedY) >= Math.abs(speedX) * this.inertiaDeltaX) {
this._nextY += Math.abs(2 / (x + 0.55) - 0.3) * speedY * 15 * stepModifer;
}
}
// If we have mixed environment we use the default behaviour. i.e. touchscreen + mouse
this._scrollTo(this._nextX, this._nextY);
this._touchInertiaAnimID = requestAnimationFrame(inertiaStep);
};
// Start inertia and continue it recursively
this._touchInertiaAnimID = requestAnimationFrame(inertiaStep);
}
private calcAxisCoords(target, min, max) {
if (target === undefined || target < min) {
target = min;
} else if (target > max) {
target = max;
}
return target;
}
private _scrollTo(destX, destY) {
// TODO Trigger scrolling event?
const scrolledX = this._scrollToX(destX);
const scrolledY = this._scrollToY(destY);
return { x: scrolledX, y: scrolledY };
}
private _scrollToX(dest) {
this.IgxScrollInertiaScrollContainer.scrollLeft = dest;
}
private _scrollToY(dest) {
this.IgxScrollInertiaScrollContainer.scrollTop = dest;
}
}
/**
* @hidden
*/