mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-20 12:29:55 +08:00
refactor(scroll): js scrolling ability
Required for Virtual Scrolling on iOS UIWebView
This commit is contained in:
@ -1,121 +0,0 @@
|
|||||||
import {raf} from '../util/dom';
|
|
||||||
|
|
||||||
|
|
||||||
export class ScrollTo {
|
|
||||||
public isPlaying: boolean;
|
|
||||||
private _el: HTMLElement;
|
|
||||||
|
|
||||||
constructor(ele: any) {
|
|
||||||
if (typeof ele === 'string') {
|
|
||||||
// string query selector
|
|
||||||
ele = document.querySelector(ele);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ele) {
|
|
||||||
if (ele.nativeElement) {
|
|
||||||
// angular ElementRef
|
|
||||||
ele = ele.nativeElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ele.nodeType === 1) {
|
|
||||||
this._el = ele;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start(x: number, y: number, duration: number, tolerance?: number): Promise<any> {
|
|
||||||
// scroll animation loop w/ easing
|
|
||||||
// credit https://gist.github.com/dezinezync/5487119
|
|
||||||
let self = this;
|
|
||||||
|
|
||||||
if (!self._el) {
|
|
||||||
// invalid element
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
x = x || 0;
|
|
||||||
y = y || 0;
|
|
||||||
tolerance = tolerance || 0;
|
|
||||||
|
|
||||||
let fromY = self._el.scrollTop;
|
|
||||||
let fromX = self._el.scrollLeft;
|
|
||||||
|
|
||||||
let xDistance = Math.abs(x - fromX);
|
|
||||||
let yDistance = Math.abs(y - fromY);
|
|
||||||
|
|
||||||
console.debug(`scrollTo start, y: ${y}, fromY: ${fromY}, yDistance: ${yDistance}, duration: ${duration}, tolerance: ${tolerance}`);
|
|
||||||
|
|
||||||
if (yDistance <= tolerance && xDistance <= tolerance) {
|
|
||||||
// prevent scrolling if already close to there
|
|
||||||
self._el = null;
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let startTime: number;
|
|
||||||
|
|
||||||
// scroll loop
|
|
||||||
function step() {
|
|
||||||
if (!self._el) {
|
|
||||||
return resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
let time = Math.min(1, ((Date.now() - startTime) / duration));
|
|
||||||
|
|
||||||
// where .5 would be 50% of time on a linear scale easedT gives a
|
|
||||||
// fraction based on the easing method
|
|
||||||
let easedT = easeOutCubic(time);
|
|
||||||
|
|
||||||
if (fromY != y) {
|
|
||||||
self._el.scrollTop = (easedT * (y - fromY)) + fromY;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fromX != x) {
|
|
||||||
self._el.scrollLeft = Math.round((easedT * (x - fromX)) + fromX);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.debug(`scrollTo step, easedT: ${easedT}, scrollTop: ${self._el.scrollTop}`);
|
|
||||||
|
|
||||||
if (time < 1 && self.isPlaying) {
|
|
||||||
raf(step);
|
|
||||||
|
|
||||||
} else if (!self.isPlaying) {
|
|
||||||
// stopped
|
|
||||||
self._el = null;
|
|
||||||
reject();
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// done
|
|
||||||
self._el = null;
|
|
||||||
console.debug(`scrollTo done`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// start scroll loop
|
|
||||||
self.isPlaying = true;
|
|
||||||
|
|
||||||
// chill out for a frame first
|
|
||||||
raf(() => {
|
|
||||||
startTime = Date.now();
|
|
||||||
raf(step);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.isPlaying = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose() {
|
|
||||||
this.stop();
|
|
||||||
this._el = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// decelerating to zero velocity
|
|
||||||
function easeOutCubic(t) {
|
|
||||||
return (--t) * t * t + 1;
|
|
||||||
}
|
|
@ -158,6 +158,17 @@ scroll-content {
|
|||||||
will-change: scroll-position;
|
will-change: scroll-position;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ion-content.js-scroll > scroll-content {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
min-height: 100%;
|
||||||
|
|
||||||
|
overflow-x: initial;
|
||||||
|
overflow-y: initial;
|
||||||
|
-webkit-overflow-scrolling: auto;
|
||||||
|
will-change: initial;
|
||||||
|
}
|
||||||
|
|
||||||
ion-tabbar {
|
ion-tabbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
@ -3,10 +3,10 @@ import {Component, ElementRef, Optional, NgZone} from 'angular2/core';
|
|||||||
import {Ion} from '../ion';
|
import {Ion} from '../ion';
|
||||||
import {IonicApp} from '../app/app';
|
import {IonicApp} from '../app/app';
|
||||||
import {Config} from '../../config/config';
|
import {Config} from '../../config/config';
|
||||||
import {raf, transitionEnd} from '../../util/dom';
|
import {raf, transitionEnd, pointerCoord} from '../../util/dom';
|
||||||
import {ViewController} from '../nav/view-controller';
|
import {ViewController} from '../nav/view-controller';
|
||||||
import {Animation} from '../../animations/animation';
|
import {Animation} from '../../animations/animation';
|
||||||
import {ScrollTo} from '../../animations/scroll-to';
|
import {ScrollView} from '../../util/scroll-view';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @name Content
|
* @name Content
|
||||||
@ -36,7 +36,7 @@ import {ScrollTo} from '../../animations/scroll-to';
|
|||||||
})
|
})
|
||||||
export class Content extends Ion {
|
export class Content extends Ion {
|
||||||
private _padding: number = 0;
|
private _padding: number = 0;
|
||||||
private _scrollTo: ScrollTo;
|
private _scroll: ScrollView;
|
||||||
private _scLsn: Function;
|
private _scLsn: Function;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -66,13 +66,15 @@ export class Content extends Ion {
|
|||||||
let self = this;
|
let self = this;
|
||||||
self.scrollElement = self._elementRef.nativeElement.children[0];
|
self.scrollElement = self._elementRef.nativeElement.children[0];
|
||||||
|
|
||||||
if (self._config.get('tapPolyfill') === true) {
|
self._zone.runOutsideAngular(function() {
|
||||||
self._zone.runOutsideAngular(function() {
|
self._scroll = new ScrollView(self.scrollElement);
|
||||||
|
|
||||||
|
if (self._config.getBoolean('tapPolyfill')) {
|
||||||
self._scLsn = self.addScrollListener(function() {
|
self._scLsn = self.addScrollListener(function() {
|
||||||
self._app.setScrolling();
|
self._app.setScrolling();
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -80,6 +82,7 @@ export class Content extends Ion {
|
|||||||
*/
|
*/
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this._scLsn && this._scLsn();
|
this._scLsn && this._scLsn();
|
||||||
|
this._scroll && this._scroll.destroy();
|
||||||
this.scrollElement = this._scLsn = null;
|
this.scrollElement = this._scLsn = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,17 +236,10 @@ export class Content extends Ion {
|
|||||||
* @param {number} x The x-value to scroll to.
|
* @param {number} x The x-value to scroll to.
|
||||||
* @param {number} y The y-value to scroll to.
|
* @param {number} y The y-value to scroll to.
|
||||||
* @param {number} duration Duration of the scroll animation in ms.
|
* @param {number} duration Duration of the scroll animation in ms.
|
||||||
* @param {TODO} tolerance TODO
|
|
||||||
* @returns {Promise} Returns a promise when done
|
* @returns {Promise} Returns a promise when done
|
||||||
*/
|
*/
|
||||||
scrollTo(x: number, y: number, duration: number, tolerance?: number): Promise<any> {
|
scrollTo(x: number, y: number, duration: number): Promise<any> {
|
||||||
if (this._scrollTo) {
|
return this._scroll.scrollTo(x, y, duration);
|
||||||
this._scrollTo.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._scrollTo = new ScrollTo(this.scrollElement);
|
|
||||||
|
|
||||||
return this._scrollTo.start(x, y, duration, tolerance);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -271,21 +267,29 @@ export class Content extends Ion {
|
|||||||
* ```
|
* ```
|
||||||
* @returns {Promise} Returns a promise when done
|
* @returns {Promise} Returns a promise when done
|
||||||
*/
|
*/
|
||||||
scrollToTop() {
|
scrollToTop(duration: number = 300) {
|
||||||
if (this._scrollTo) {
|
return this.scrollTo(0, 0, duration);
|
||||||
this._scrollTo.dispose();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
this._scrollTo = new ScrollTo(this.scrollElement);
|
/**
|
||||||
|
* @private
|
||||||
return this._scrollTo.start(0, 0, 300, 0);
|
*/
|
||||||
|
jsScroll(onScrollCallback: Function): Function {
|
||||||
|
return this._scroll.jsScroll(onScrollCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
getScrollTop(): number {
|
getScrollTop(): number {
|
||||||
return this.getNativeElement().scrollTop;
|
return this._scroll.getTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
setScrollTop(top: number) {
|
||||||
|
this._scroll.setTop(top);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -13,7 +13,7 @@ import {MenuController} from '../components/menu/menu-controller';
|
|||||||
import {NavRegistry} from '../components/nav/nav-registry';
|
import {NavRegistry} from '../components/nav/nav-registry';
|
||||||
import {Platform} from '../platform/platform';
|
import {Platform} from '../platform/platform';
|
||||||
import {ready, closest} from '../util/dom';
|
import {ready, closest} from '../util/dom';
|
||||||
import {ScrollTo} from '../animations/scroll-to';
|
import {ScrollView} from '../util/scroll-view';
|
||||||
import {TapClick} from '../components/tap-click/tap-click';
|
import {TapClick} from '../components/tap-click/tap-click';
|
||||||
import {Translate} from '../translation/translate';
|
import {Translate} from '../translation/translate';
|
||||||
|
|
||||||
@ -144,8 +144,8 @@ function bindEvents(window, document, platform, events) {
|
|||||||
|
|
||||||
var content = closest(el, 'scroll-content');
|
var content = closest(el, 'scroll-content');
|
||||||
if (content) {
|
if (content) {
|
||||||
var scrollTo = new ScrollTo(content);
|
var scroll = new ScrollView(content);
|
||||||
scrollTo.start(0, 0, 300, 0);
|
scroll.scrollTo(0, 0, 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
265
ionic/util/scroll-view.ts
Normal file
265
ionic/util/scroll-view.ts
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import {CSS, pointerCoord, raf, cancelRaf} from '../util/dom';
|
||||||
|
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
constructor(ele: HTMLElement) {
|
||||||
|
this._el = ele;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTop(): number {
|
||||||
|
if (this._js) {
|
||||||
|
return this._top;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this._top = this._el.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTop(top: number) {
|
||||||
|
this._top = top;
|
||||||
|
|
||||||
|
if (this._js) {
|
||||||
|
this._el.style[CSS.transform] = `translate3d(0px,${top * -1}px,0px)`;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this._el.scrollTop = top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTo(x: number, y: number, duration: number): Promise<any> {
|
||||||
|
// scroll animation loop w/ easing
|
||||||
|
// credit https://gist.github.com/dezinezync/5487119
|
||||||
|
let self = this;
|
||||||
|
|
||||||
|
if (!self._el) {
|
||||||
|
// invalid element
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
x = x || 0;
|
||||||
|
y = y || 0;
|
||||||
|
|
||||||
|
let fromY = self._el.scrollTop;
|
||||||
|
let fromX = self._el.scrollLeft;
|
||||||
|
|
||||||
|
let xDistance = Math.abs(x - fromX);
|
||||||
|
let yDistance = Math.abs(y - fromY);
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let startTime: number;
|
||||||
|
|
||||||
|
// scroll loop
|
||||||
|
function step() {
|
||||||
|
if (!self._el || !self.isPlaying) {
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
let time = Math.min(1, ((Date.now() - startTime) / duration));
|
||||||
|
|
||||||
|
// where .5 would be 50% of time on a linear scale easedT gives a
|
||||||
|
// fraction based on the easing method
|
||||||
|
let easedT = (--time) * time * time + 1;
|
||||||
|
|
||||||
|
if (fromY != y) {
|
||||||
|
self.setTop((easedT * (y - fromY)) + fromY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromX != x) {
|
||||||
|
self._el.scrollLeft = Math.round((easedT * (x - fromX)) + fromX);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time < 1 && self.isPlaying) {
|
||||||
|
raf(step);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// done
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// start scroll loop
|
||||||
|
self.isPlaying = true;
|
||||||
|
|
||||||
|
// chill out for a frame first
|
||||||
|
raf(() => {
|
||||||
|
startTime = Date.now();
|
||||||
|
raf(step);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
// 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 = raf(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 = raf(self._decelerate.bind(self));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this._velocity = 0;
|
||||||
|
this.stop();
|
||||||
|
this._el = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_VELOCITY = 150;
|
||||||
|
const MIN_VELOCITY_START_DECELERATION = 4;
|
||||||
|
const MIN_VELOCITY_CONTINUE_DECELERATION = 0.12;
|
||||||
|
const DECELERATION_FRICTION = 0.97;
|
||||||
|
const FRAME_MS = (1000 / 60);
|
Reference in New Issue
Block a user