From f3d92b8eb8cab62ec6c4915fa5d23e83c4d5925f Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Tue, 5 Apr 2016 14:33:40 -0500 Subject: [PATCH] refactor(scroll): js scrolling ability Required for Virtual Scrolling on iOS UIWebView --- ionic/animations/scroll-to.ts | 121 ------------- ionic/components/app/structure.scss | 11 ++ ionic/components/content/content.ts | 52 +++--- ionic/config/bootstrap.ts | 6 +- ionic/util/scroll-view.ts | 265 ++++++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 148 deletions(-) delete mode 100644 ionic/animations/scroll-to.ts create mode 100644 ionic/util/scroll-view.ts diff --git a/ionic/animations/scroll-to.ts b/ionic/animations/scroll-to.ts deleted file mode 100644 index 39ee81fae1..0000000000 --- a/ionic/animations/scroll-to.ts +++ /dev/null @@ -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 { - // 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; -} diff --git a/ionic/components/app/structure.scss b/ionic/components/app/structure.scss index 9467f3c9c1..e2191cfdb3 100644 --- a/ionic/components/app/structure.scss +++ b/ionic/components/app/structure.scss @@ -158,6 +158,17 @@ scroll-content { 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 { position: absolute; top: 0; diff --git a/ionic/components/content/content.ts b/ionic/components/content/content.ts index f6068b51d5..6c0da4c4c3 100644 --- a/ionic/components/content/content.ts +++ b/ionic/components/content/content.ts @@ -3,10 +3,10 @@ import {Component, ElementRef, Optional, NgZone} from 'angular2/core'; import {Ion} from '../ion'; import {IonicApp} from '../app/app'; 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 {Animation} from '../../animations/animation'; -import {ScrollTo} from '../../animations/scroll-to'; +import {ScrollView} from '../../util/scroll-view'; /** * @name Content @@ -36,7 +36,7 @@ import {ScrollTo} from '../../animations/scroll-to'; }) export class Content extends Ion { private _padding: number = 0; - private _scrollTo: ScrollTo; + private _scroll: ScrollView; private _scLsn: Function; /** @@ -66,13 +66,15 @@ export class Content extends Ion { let self = this; 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._app.setScrolling(); }); - }); - } + } + }); } /** @@ -80,6 +82,7 @@ export class Content extends Ion { */ ngOnDestroy() { this._scLsn && this._scLsn(); + this._scroll && this._scroll.destroy(); 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} y The y-value to scroll to. * @param {number} duration Duration of the scroll animation in ms. - * @param {TODO} tolerance TODO * @returns {Promise} Returns a promise when done */ - scrollTo(x: number, y: number, duration: number, tolerance?: number): Promise { - if (this._scrollTo) { - this._scrollTo.dispose(); - } - - this._scrollTo = new ScrollTo(this.scrollElement); - - return this._scrollTo.start(x, y, duration, tolerance); + scrollTo(x: number, y: number, duration: number): Promise { + return this._scroll.scrollTo(x, y, duration); } /** @@ -271,21 +267,29 @@ export class Content extends Ion { * ``` * @returns {Promise} Returns a promise when done */ - scrollToTop() { - if (this._scrollTo) { - this._scrollTo.dispose(); - } + scrollToTop(duration: number = 300) { + return this.scrollTo(0, 0, duration); + } - this._scrollTo = new ScrollTo(this.scrollElement); - - return this._scrollTo.start(0, 0, 300, 0); + /** + * @private + */ + jsScroll(onScrollCallback: Function): Function { + return this._scroll.jsScroll(onScrollCallback); } /** * @private */ getScrollTop(): number { - return this.getNativeElement().scrollTop; + return this._scroll.getTop(); + } + + /** + * @private + */ + setScrollTop(top: number) { + this._scroll.setTop(top); } /** diff --git a/ionic/config/bootstrap.ts b/ionic/config/bootstrap.ts index d588972e7d..4593b369fd 100644 --- a/ionic/config/bootstrap.ts +++ b/ionic/config/bootstrap.ts @@ -13,7 +13,7 @@ import {MenuController} from '../components/menu/menu-controller'; import {NavRegistry} from '../components/nav/nav-registry'; import {Platform} from '../platform/platform'; 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 {Translate} from '../translation/translate'; @@ -144,8 +144,8 @@ function bindEvents(window, document, platform, events) { var content = closest(el, 'scroll-content'); if (content) { - var scrollTo = new ScrollTo(content); - scrollTo.start(0, 0, 300, 0); + var scroll = new ScrollView(content); + scroll.scrollTo(0, 0, 300); } }); diff --git a/ionic/util/scroll-view.ts b/ionic/util/scroll-view.ts new file mode 100644 index 0000000000..57355307f1 --- /dev/null +++ b/ionic/util/scroll-view.ts @@ -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; + 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 { + // 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);