diff --git a/src/components/app/app.ts b/src/components/app/app.ts index f499b97013..7b8de851a9 100644 --- a/src/components/app/app.ts +++ b/src/components/app/app.ts @@ -161,8 +161,8 @@ export class App { /** * @private */ - setScrolling() { - this._scrollTime = Date.now() + ACTIVE_SCROLLING_TIME; + setScrolling(timeStamp: number) { + this._scrollTime = timeStamp + ACTIVE_SCROLLING_TIME; } /** diff --git a/src/components/content/content.ts b/src/components/content/content.ts index 315c603e6b..2c7dd243ad 100644 --- a/src/components/content/content.ts +++ b/src/components/content/content.ts @@ -1,14 +1,19 @@ -import { ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, Optional, Renderer, ViewEncapsulation } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, NgZone, OnDestroy, AfterViewInit, Optional, Output, Renderer, ViewEncapsulation } from '@angular/core'; import { App } from '../app/app'; -import { Ion } from '../ion'; import { Config } from '../../config/config'; +import { DomController } from '../../util/dom-controller'; +import { eventOptions } from '../../util/ui-event-manager'; +import { Img } from '../img/img'; +import { Ion } from '../ion'; +import { isTrueProperty, assert, removeArrayItem } from '../../util/util'; import { Keyboard } from '../../util/keyboard'; -import { nativeRaf, nativeTimeout, transitionEnd } from '../../util/dom'; -import { ScrollView } from '../../util/scroll-view'; +import { ScrollView, ScrollDirection } from '../../util/scroll-view'; import { Tabs } from '../tabs/tabs'; +import { transitionEnd } from '../../util/dom'; import { ViewController } from '../../navigation/view-controller'; -import { isTrueProperty, assert } from '../../util/util'; + +export { ScrollEvent, ScrollDirection } from '../../util/scroll-view'; /** @@ -116,32 +121,51 @@ import { isTrueProperty, assert } from '../../util/util'; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class Content extends Ion { - _paddingTop: number; - _paddingRight: number; - _paddingBottom: number; - _paddingLeft: number; - _scrollPadding: number = 0; - _headerHeight: number; - _footerHeight: number; - _tabbarHeight: number; - _tabsPlacement: string; - _inputPolling: boolean = false; - _scroll: ScrollView; - _scLsn: Function; +export class Content extends Ion implements AfterViewInit, OnDestroy { + /* @private */ _sbPadding: boolean; + + /* @internal */ + _cTop: number; + /* @internal */ + _cBottom: number; + /* @internal */ + _pTop: number; + /* @internal */ + _pRight: number; + /* @internal */ + _pBottom: number; + /* @internal */ + _pLeft: number; + /* @internal */ + _scrollPadding: number = 0; + /* @internal */ + _hdrHeight: number; + /* @internal */ + _ftrHeight: number; + /* @internal */ + _tabbarHeight: number; + /* @internal */ + _tabsPlacement: string; + /* @internal */ + _inputPolling: boolean = false; + /* @internal */ + _scroll: ScrollView; + /* @internal */ + _scLsn: Function; + /* @internal */ _fullscreen: boolean; + /* @internal */ + _lazyLoadImages: boolean = true; + /* @internal */ + _imgs: Img[] = []; + /* @internal */ _footerEle: HTMLElement; - _dirty: boolean = false; - - /* - * @private - */ + /* @internal */ + _dirty: boolean; + /* @internal */ _scrollEle: HTMLElement; - - /* - * @private - */ + /* @internal */ _fixedEle: HTMLElement; /** @@ -156,6 +180,100 @@ export class Content extends Ion { */ contentBottom: number; + /** + * The height the content, including content not visible + * on the screen due to overflow. + */ + scrollHeight: number = 0; + + /** + * The width the content, including content not visible + * on the screen due to overflow. + */ + scrollWidth: number = 0; + + + /** + * The distance of the content's top to its topmost visible content. + */ + get scrollTop(): number { + return this._scroll.getTop(); + } + set scrollTop(top: number) { + this._scroll.setTop(top); + } + + /** + * The distance of the content's left to its leftmost visible content. + */ + get scrollLeft(): number { + return this._scroll.getLeft(); + } + set scrollLeft(top: number) { + this._scroll.setLeft(top); + } + + /** + * If the scrollable area is actively scrolling or not. + */ + get isScrolling(): boolean { + return this._scroll.isScrolling; + } + + /** + * The current vertical scroll velocity. + */ + get velocityY(): number { + return this._scroll.ev.velocityY || 0; + } + + /** + * The current horizontal scroll velocity. + */ + get velocityX(): number { + return this._scroll.ev.velocityX || 0; + } + + /** + * The current, or last known, vertical scroll direction. + */ + get directionY(): ScrollDirection { + return this._scroll.ev.directionY; + } + + /** + * The current, or last known, horizontal scroll direction. + */ + get directionX(): ScrollDirection { + return this._scroll.ev.directionX; + } + + /** + * @private + */ + @Output() ionScrollStart: EventEmitter = new EventEmitter(); + + /** + * @private + */ + @Output() ionScroll: EventEmitter = new EventEmitter(); + + /** + * @private + */ + @Output() ionScrollEnd: EventEmitter = new EventEmitter(); + + /** + * @private + */ + @Output() readReady: EventEmitter = new EventEmitter(); + + /** + * @private + */ + @Output() writeReady: EventEmitter = new EventEmitter(); + + constructor( config: Config, elementRef: ElementRef, @@ -164,7 +282,8 @@ export class Content extends Ion { public _keyboard: Keyboard, public _zone: NgZone, @Optional() viewCtrl: ViewController, - @Optional() public _tabs: Tabs + @Optional() public _tabs: Tabs, + private _dom: DomController ) { super(config, elementRef, renderer, 'content'); @@ -174,21 +293,43 @@ export class Content extends Ion { viewCtrl._setIONContent(this); viewCtrl._setIONContentRef(elementRef); } + + this._scroll = new ScrollView(_dom); } /** * @private */ - ngOnInit() { - let children = this._elementRef.nativeElement.children; + ngAfterViewInit() { + if (this._scrollEle) return; + + const children = this._elementRef.nativeElement.children; assert(children && children.length >= 2, 'content needs at least two children'); this._fixedEle = children[0]; this._scrollEle = children[1]; - this._zone.runOutsideAngular(() => { - this._scroll = new ScrollView(this._scrollEle); - this._scLsn = this.addScrollListener(this._app.setScrolling.bind(this._app)); + // subscribe to the scroll start + this._scroll.scrollStart.subscribe(ev => { + this.ionScrollStart.emit(ev); + }); + + // subscribe to every scroll move + this._scroll.scroll.subscribe(ev => { + // remind the app that it's currently scrolling + this._app.setScrolling(ev.timeStamp); + + // emit to all of our other friends things be scrolling + this.ionScroll.emit(ev); + + this.imgsRefresh(); + }); + + // subscribe to the scroll end + this._scroll.scrollEnd.subscribe(ev => { + this.ionScrollEnd.emit(ev); + + this.imgsRefresh(); }); } @@ -198,72 +339,67 @@ export class Content extends Ion { ngOnDestroy() { this._scLsn && this._scLsn(); this._scroll && this._scroll.destroy(); - this._scrollEle = this._footerEle = this._scLsn = this._scroll = null; - } - - /** - * @private - */ - addScrollListener(handler: any) { - return this._addListener('scroll', handler); + this._scrollEle = this._fixedEle = this._footerEle = this._scLsn = this._scroll = null; } /** * @private */ addTouchStartListener(handler: any) { - return this._addListener('touchstart', handler); + return this._addListener('touchstart', handler, false); } /** * @private */ addTouchMoveListener(handler: any) { - return this._addListener('touchmove', handler); + return this._addListener('touchmove', handler, true); } /** * @private */ addTouchEndListener(handler: any) { - return this._addListener('touchend', handler); + return this._addListener('touchend', handler, false); } /** * @private */ addMouseDownListener(handler: any) { - return this._addListener('mousedown', handler); + return this._addListener('mousedown', handler, false); } /** * @private */ addMouseUpListener(handler: any) { - return this._addListener('mouseup', handler); + return this._addListener('mouseup', handler, false); } /** * @private */ addMouseMoveListener(handler: any) { - return this._addListener('mousemove', handler); + return this._addListener('mousemove', handler, true); } /** * @private */ - _addListener(type: string, handler: any): Function { + _addListener(type: string, handler: any, usePassive: boolean): Function { assert(handler, 'handler must be valid'); assert(this._scrollEle, '_scrollEle must be valid'); + const opts = eventOptions(false, usePassive); + // ensure we're not creating duplicates - this._scrollEle.removeEventListener(type, handler); - this._scrollEle.addEventListener(type, handler); + this._scrollEle.removeEventListener(type, handler, opts); + this._scrollEle.addEventListener(type, handler, opts); return () => { if (this._scrollEle) { - this._scrollEle.removeEventListener(type, handler); + this._scrollEle.removeEventListener(type, handler, opts); } }; } @@ -275,42 +411,6 @@ export class Content extends Ion { return this._scrollEle; } - /** - * @private - * Call a method when scrolling has stopped - * @param {Function} callback The method you want perform when scrolling has ended - */ - onScrollEnd(callback: Function) { - let lastScrollTop: number = null; - let framesUnchanged: number = 0; - let _scrollEle = this._scrollEle; - - function next() { - let currentScrollTop = _scrollEle.scrollTop; - if (lastScrollTop !== null) { - - if (Math.round(lastScrollTop) === Math.round(currentScrollTop)) { - framesUnchanged++; - - } else { - framesUnchanged = 0; - } - - if (framesUnchanged > 9) { - return callback(); - } - } - - lastScrollTop = currentScrollTop; - - nativeRaf(() => { - nativeRaf(next); - }); - } - - nativeTimeout(next, 100); - } - /** * @private */ @@ -342,23 +442,6 @@ export class Content extends Ion { return this._scroll.scrollToTop(duration); } - /** - * Get the `scrollTop` property of the content's scrollable element. - * @returns {number} - */ - getScrollTop(): number { - return this._scroll.getTop(); - } - - /** - * Set the `scrollTop` property of the content's scrollable element. - * @param {number} top - */ - setScrollTop(top: number) { - console.debug(`content, setScrollTop, top: ${top}`); - this._scroll.setTop(top); - } - /** * Scroll to the bottom of the content component. * @param {number} [duration] Duration of the scroll animation in milliseconds. Defaults to `300`. @@ -372,8 +455,8 @@ export class Content extends Ion { /** * @private */ - jsScroll(onScrollCallback: Function): Function { - return this._scroll.jsScroll(onScrollCallback); + enableJsScroll() { + this._scroll.enableJsScroll(this.contentTop, this.contentBottom); } /** @@ -392,12 +475,43 @@ export class Content extends Ion { this._fullscreen = isTrueProperty(val); } + /** + * @private + */ + @Input() + get lazyLoadImages(): boolean { + return !!this._lazyLoadImages; + } + set lazyLoadImages(val: boolean) { + this._lazyLoadImages = isTrueProperty(val); + } + + /** + * @private + */ + addImg(img: Img) { + this._imgs.push(img); + } + + /** + * @private + */ + removeImg(img: Img) { + removeArrayItem(this._imgs, img); + } + + /** + * @private + */ + /** * @private * DOM WRITE */ setScrollElementStyle(prop: string, val: any) { - (this._scrollEle.style)[prop] = val; + this._dom.write(() => { + (this._scrollEle.style)[prop] = val; + }); } /** @@ -448,7 +562,9 @@ export class Content extends Ion { console.debug(`content, addScrollPadding, newPadding: ${newPadding}, this._scrollPadding: ${this._scrollPadding}`); this._scrollPadding = newPadding; - this._scrollEle.style.paddingBottom = (newPadding > 0) ? newPadding + 'px' : ''; + this._dom.write(() => { + this._scrollEle.style.paddingBottom = (newPadding > 0) ? newPadding + 'px' : ''; + }); } } @@ -475,10 +591,8 @@ export class Content extends Ion { * after dynamically adding headers, footers, or tabs. */ resize() { - nativeRaf(() => { - this.readDimensions(); - this.writeDimensions(); - }); + this._dom.read(this.readDimensions, this); + this._dom.write(this.writeDimensions, this); } /** @@ -486,20 +600,22 @@ export class Content extends Ion { * DOM READ */ readDimensions() { - let cachePaddingTop = this._paddingTop; - let cachePaddingRight = this._paddingRight; - let cachePaddingBottom = this._paddingBottom; - let cachePaddingLeft = this._paddingLeft; - let cacheHeaderHeight = this._headerHeight; - let cacheFooterHeight = this._footerHeight; + let cachePaddingTop = this._pTop; + let cachePaddingRight = this._pRight; + let cachePaddingBottom = this._pBottom; + let cachePaddingLeft = this._pLeft; + let cacheHeaderHeight = this._hdrHeight; + let cacheFooterHeight = this._ftrHeight; let cacheTabsPlacement = this._tabsPlacement; - this._paddingTop = 0; - this._paddingRight = 0; - this._paddingBottom = 0; - this._paddingLeft = 0; - this._headerHeight = 0; - this._footerHeight = 0; + this.scrollWidth = 0; + this.scrollHeight = 0; + this._pTop = 0; + this._pRight = 0; + this._pBottom = 0; + this._pLeft = 0; + this._hdrHeight = 0; + this._ftrHeight = 0; this._tabsPlacement = null; let ele: HTMLElement = this._elementRef.nativeElement; @@ -516,19 +632,27 @@ export class Content extends Ion { ele = children[i]; tagName = ele.tagName; if (tagName === 'ION-CONTENT') { + // ******** DOM READ **************** + this.scrollWidth = ele.scrollWidth; + // ******** DOM READ **************** + this.scrollHeight = ele.scrollHeight; + if (this._fullscreen) { + // ******** DOM READ **************** computedStyle = getComputedStyle(ele); - this._paddingTop = parsePxUnit(computedStyle.paddingTop); - this._paddingBottom = parsePxUnit(computedStyle.paddingBottom); - this._paddingRight = parsePxUnit(computedStyle.paddingRight); - this._paddingLeft = parsePxUnit(computedStyle.paddingLeft); + this._pTop = parsePxUnit(computedStyle.paddingTop); + this._pBottom = parsePxUnit(computedStyle.paddingBottom); + this._pRight = parsePxUnit(computedStyle.paddingRight); + this._pLeft = parsePxUnit(computedStyle.paddingLeft); } } else if (tagName === 'ION-HEADER') { - this._headerHeight = ele.clientHeight; + // ******** DOM READ **************** + this._hdrHeight = ele.clientHeight; } else if (tagName === 'ION-FOOTER') { - this._footerHeight = ele.clientHeight; + // ******** DOM READ **************** + this._ftrHeight = ele.clientHeight; this._footerEle = ele; } } @@ -540,6 +664,7 @@ export class Content extends Ion { if (ele.tagName === 'ION-TABS') { tabbarEle = ele.firstElementChild; + // ******** DOM READ **************** this._tabbarHeight = tabbarEle.clientHeight; if (this._tabsPlacement === null) { @@ -551,15 +676,42 @@ export class Content extends Ion { ele = ele.parentElement; } + // Toolbar height + this._cTop = this._hdrHeight; + this._cBottom = this._ftrHeight; + + // Tabs height + if (this._tabsPlacement === 'top') { + this._cTop += this._tabbarHeight; + + } else if (this._tabsPlacement === 'bottom') { + this._cBottom += this._tabbarHeight; + } + + // Handle fullscreen viewport (padding vs margin) + if (this._fullscreen) { + this._cTop += this._pTop; + this._cBottom += this._pBottom; + } + this._dirty = ( - cachePaddingTop !== this._paddingTop || - cachePaddingBottom !== this._paddingBottom || - cachePaddingLeft !== this._paddingLeft || - cachePaddingRight !== this._paddingRight || - cacheHeaderHeight !== this._headerHeight || - cacheFooterHeight !== this._footerHeight || - cacheTabsPlacement !== this._tabsPlacement + cachePaddingTop !== this._pTop || + cachePaddingBottom !== this._pBottom || + cachePaddingLeft !== this._pLeft || + cachePaddingRight !== this._pRight || + cacheHeaderHeight !== this._hdrHeight || + cacheFooterHeight !== this._ftrHeight || + cacheTabsPlacement !== this._tabsPlacement || + this._cTop !== this.contentTop || + this._cBottom !== this.contentBottom ); + + this._scroll.init(this._scrollEle, this._cTop, this._cBottom); + + // initial imgs refresh + this.imgsRefresh(); + + this.readReady.emit(); } /** @@ -572,91 +724,194 @@ export class Content extends Ion { return; } - let scrollEle = this._scrollEle as any; + const scrollEle = this._scrollEle as any; if (!scrollEle) { assert(false, 'this._scrollEle should be valid'); return; } - let fixedEle = this._fixedEle; + const fixedEle = this._fixedEle; if (!fixedEle) { assert(false, 'this._fixedEle should be valid'); return; } - // Toolbar height - let contentTop = this._headerHeight; - let contentBottom = this._footerHeight; - // Tabs height - if (this._tabsPlacement === 'top') { - assert(this._tabbarHeight >= 0, '_tabbarHeight has to be positive'); - contentTop += this._tabbarHeight; - - } else if (this._tabsPlacement === 'bottom') { - assert(this._tabbarHeight >= 0, '_tabbarHeight has to be positive'); - contentBottom += this._tabbarHeight; - - // Update footer position - if (contentBottom > 0 && this._footerEle) { - let footerPos = contentBottom - this._footerHeight; - assert(footerPos >= 0, 'footerPos has to be positive'); - - this._footerEle.style.bottom = cssFormat(footerPos); - } + if (this._tabsPlacement === 'bottom' && this._cBottom > 0 && this._footerEle) { + var footerPos = this._cBottom - this._ftrHeight; + assert(footerPos >= 0, 'footerPos has to be positive'); + // ******** DOM WRITE **************** + this._footerEle.style.bottom = cssFormat(footerPos); } // Handle fullscreen viewport (padding vs margin) let topProperty = 'marginTop'; let bottomProperty = 'marginBottom'; - let fixedTop: number = contentTop; - let fixedBottom: number = contentBottom; + let fixedTop: number = this._cTop; + let fixedBottom: number = this._cBottom; + if (this._fullscreen) { - assert(this._paddingTop >= 0, '_paddingTop has to be positive'); - assert(this._paddingBottom >= 0, '_paddingBottom has to be positive'); + assert(this._pTop >= 0, '_paddingTop has to be positive'); + assert(this._pBottom >= 0, '_paddingBottom has to be positive'); // adjust the content with padding, allowing content to scroll under headers/footers // however, on iOS you cannot control the margins of the scrollbar (last tested iOS9.2) // only add inline padding styles if the computed padding value, which would // have come from the app's css, is different than the new padding value - contentTop += this._paddingTop; - contentBottom += this._paddingBottom; topProperty = 'paddingTop'; bottomProperty = 'paddingBottom'; } // Only update top margin if value changed - if (contentTop !== this.contentTop) { - assert(contentTop >= 0, 'contentTop has to be positive'); + if (this._cTop !== this.contentTop) { + assert(this._cTop >= 0, 'contentTop has to be positive'); assert(fixedTop >= 0, 'fixedTop has to be positive'); - scrollEle.style[topProperty] = cssFormat(contentTop); + // ******** DOM WRITE **************** + scrollEle.style[topProperty] = cssFormat(this._cTop); + // ******** DOM WRITE **************** fixedEle.style.marginTop = cssFormat(fixedTop); - this.contentTop = contentTop; + + this.contentTop = this._cTop; } // Only update bottom margin if value changed - if (contentBottom !== this.contentBottom) { - assert(contentBottom >= 0, 'contentBottom has to be positive'); + if (this._cBottom !== this.contentBottom) { + assert(this._cBottom >= 0, 'contentBottom has to be positive'); assert(fixedBottom >= 0, 'fixedBottom has to be positive'); - scrollEle.style[bottomProperty] = cssFormat(contentBottom); + // ******** DOM WRITE **************** + scrollEle.style[bottomProperty] = cssFormat(this._cBottom); + // ******** DOM WRITE **************** fixedEle.style.marginBottom = cssFormat(fixedBottom); - this.contentBottom = contentBottom; + + this.contentBottom = this._cBottom; } if (this._tabsPlacement !== null && this._tabs) { // set the position of the tabbar if (this._tabsPlacement === 'top') { - this._tabs.setTabbarPosition(this._headerHeight, -1); + // ******** DOM WRITE **************** + this._tabs.setTabbarPosition(this._hdrHeight, -1); } else { assert(this._tabsPlacement === 'bottom', 'tabsPlacement should be bottom'); + // ******** DOM WRITE **************** this._tabs.setTabbarPosition(-1, 0); } } + + this.writeReady.emit(); } + /** + * @private + */ + imgsRefresh() { + if (this._imgs.length && this.isImgsRefreshable()) { + loadImgs(this._imgs, this.scrollTop, this.scrollHeight, this.directionY, IMG_REQUESTABLE_BUFFER, IMG_RENDERABLE_BUFFER); + } + } + + isImgsRefreshable() { + return Math.abs(this.velocityY) < 3; + } + +} + +export function loadImgs(imgs: Img[], scrollTop: number, scrollHeight: number, scrollDirectionY: ScrollDirection, requestableBuffer: number, renderableBuffer: number) { + const scrollBottom = (scrollTop + scrollHeight); + const priority1: Img[] = []; + const priority2: Img[] = []; + let img: Img; + + // all images should be paused + for (var i = 0, ilen = imgs.length; i < ilen; i++) { + img = imgs[i]; + + if (scrollDirectionY === ScrollDirection.Up) { + // scrolling up + if (img.top < scrollBottom && img.bottom > scrollTop - renderableBuffer) { + // scrolling up, img is within viewable area + // or about to be viewable area + img.canRequest = img.canRender = true; + priority1.push(img); + continue; + } + + if (img.bottom <= scrollTop && img.bottom > scrollTop - requestableBuffer) { + // scrolling up, img is within requestable area + img.canRequest = true; + img.canRender = false; + priority2.push(img); + continue; + } + + if (img.top >= scrollBottom && img.top < scrollBottom + renderableBuffer) { + // scrolling up, img below viewable area + // but it's still within renderable area + // don't allow a reset + img.canRequest = img.canRender = false; + continue; + } + + } else { + // scrolling down + + if (img.bottom > scrollTop && img.top < scrollBottom + renderableBuffer) { + // scrolling down, img is within viewable area + // or about to be viewable area + img.canRequest = img.canRender = true; + priority1.push(img); + continue; + } + + if (img.top >= scrollBottom && img.top < scrollBottom + requestableBuffer) { + // scrolling down, img is within requestable area + img.canRequest = true; + img.canRender = false; + priority2.push(img); + continue; + } + + if (img.bottom <= scrollTop && img.bottom > scrollTop - renderableBuffer) { + // scrolling down, img above viewable area + // but it's still within renderable area + // don't allow a reset + img.canRequest = img.canRender = false; + continue; + } + } + + img.canRequest = img.canRender = false; + img.reset(); + } + + // update all imgs which are viewable + priority1.sort(sortTopToBottom).forEach(i => i.update()); + + if (scrollDirectionY === ScrollDirection.Up) { + // scrolling up + priority2.sort(sortTopToBottom).reverse().forEach(i => i.update()); + + } else { + // scrolling down + priority2.sort(sortTopToBottom).forEach(i => i.update()); + } +} + +const IMG_REQUESTABLE_BUFFER = 1200; +const IMG_RENDERABLE_BUFFER = 200; + + +function sortTopToBottom(a: Img, b: Img) { + if (a.top < b.top) { + return -1; + } + if (a.top > b.top) { + return 1; + } + return 0; } function parsePxUnit(val: string): number { diff --git a/src/components/infinite-scroll/infinite-scroll.ts b/src/components/infinite-scroll/infinite-scroll.ts index dbe7877142..2e0d681960 100644 --- a/src/components/infinite-scroll/infinite-scroll.ts +++ b/src/components/infinite-scroll/infinite-scroll.ts @@ -1,6 +1,7 @@ import { Directive, ElementRef, EventEmitter, Host, Input, NgZone, Output } from '@angular/core'; -import { Content } from '../content/content'; +import { Content, ScrollEvent } from '../content/content'; +import { DomController } from '../../util/dom-controller'; /** @@ -97,7 +98,7 @@ import { Content } from '../content/content'; export class InfiniteScroll { _lastCheck: number = 0; _highestY: number = 0; - _scLsn: Function; + _scLsn: any; _thr: string = '15%'; _thrPx: number = 0; _thrPc: number = 0.15; @@ -156,31 +157,32 @@ export class InfiniteScroll { constructor( @Host() private _content: Content, private _zone: NgZone, - private _elementRef: ElementRef + private _elementRef: ElementRef, + private _dom: DomController ) { _content.setElementClass('has-infinite-scroll', true); } - _onScroll() { + _onScroll(ev: ScrollEvent) { if (this.state === STATE_LOADING || this.state === STATE_DISABLED) { return 1; } - let now = Date.now(); - - if (this._lastCheck + 32 > now) { + if (this._lastCheck + 32 > ev.timeStamp) { // no need to check less than every XXms return 2; } - this._lastCheck = now; + this._lastCheck = ev.timeStamp; - let infiniteHeight = this._elementRef.nativeElement.scrollHeight; + // ******** DOM READ **************** + const infiniteHeight = this._elementRef.nativeElement.scrollHeight; if (!infiniteHeight) { // if there is no height of this element then do nothing return 3; } - let d = this._content.getContentDimensions(); + // ******** DOM READ **************** + const d = this._content.getContentDimensions(); let reloadY = d.contentHeight; if (this._thrPc) { @@ -189,13 +191,18 @@ export class InfiniteScroll { reloadY += this._thrPx; } - let distanceFromInfinite = ((d.scrollHeight - infiniteHeight) - d.scrollTop) - reloadY; + // ******** DOM READS ABOVE / DOM WRITES BELOW **************** + + const distanceFromInfinite = ((d.scrollHeight - infiniteHeight) - d.scrollTop) - reloadY; if (distanceFromInfinite < 0) { - this._zone.run(() => { - if (this.state !== STATE_LOADING && this.state !== STATE_DISABLED) { - this.state = STATE_LOADING; - this.ionInfinite.emit(this); - } + // ******** DOM WRITE **************** + this._dom.write(() => { + this._zone.run(() => { + if (this.state !== STATE_LOADING && this.state !== STATE_DISABLED) { + this.state = STATE_LOADING; + this.ionInfinite.emit(this); + } + }); }); return 5; } @@ -238,12 +245,12 @@ export class InfiniteScroll { if (this._init) { if (shouldListen) { if (!this._scLsn) { - this._zone.runOutsideAngular(() => { - this._scLsn = this._content.addScrollListener( this._onScroll.bind(this) ); + this._scLsn = this._content.ionScroll.subscribe((ev: ScrollEvent) => { + this._onScroll(ev); }); } } else { - this._scLsn && this._scLsn(); + this._scLsn && this._scLsn.unsubscribe(); this._scLsn = null; } } diff --git a/src/components/infinite-scroll/test/infinite-scroll.spec.ts b/src/components/infinite-scroll/test/infinite-scroll.spec.ts index 8c4682d2a9..3ec2f3c76d 100644 --- a/src/components/infinite-scroll/test/infinite-scroll.spec.ts +++ b/src/components/infinite-scroll/test/infinite-scroll.spec.ts @@ -1,6 +1,6 @@ -import { Content } from '../../content/content'; +import { Content, ScrollEvent } from '../../content/content'; import { InfiniteScroll } from '../infinite-scroll'; -import { mockConfig, mockElementRef, mockRenderer, mockZone } from '../../../util/mock-providers'; +import { mockConfig, MockDomController, mockElementRef, mockRenderer, mockZone } from '../../../util/mock-providers'; describe('Infinite Scroll', () => { @@ -17,7 +17,7 @@ describe('Infinite Scroll', () => { setInfiniteScrollTop(300); - var result = inf._onScroll(); + var result = inf._onScroll(ev); expect(result).toEqual(6); }); @@ -30,37 +30,38 @@ describe('Infinite Scroll', () => { setInfiniteScrollTop(300); - var result = inf._onScroll(); + var result = inf._onScroll(ev); expect(result).toEqual(5); }); it('should not run if there is not infinite element height', () => { setInfiniteScrollTop(0); - var result = inf._onScroll(); + var result = inf._onScroll(ev); expect(result).toEqual(3); }); it('should not run again if ran less than 32ms ago', () => { + ev.timeStamp = Date.now(); inf._lastCheck = Date.now(); - var result = inf._onScroll(); + var result = inf._onScroll(ev); expect(result).toEqual(2); }); it('should not run if state is disabled', () => { inf.state = 'disabled'; - var result = inf._onScroll(); + var result = inf._onScroll(ev); expect(result).toEqual(1); }); it('should not run if state is loading', () => { inf.state = 'loading'; - var result = inf._onScroll(); + var result = inf._onScroll(ev); expect(result).toEqual(1); }); it('should not run if not enabled', () => { inf.state = 'disabled'; - var result = inf._onScroll(); + var result = inf._onScroll(ev); expect(result).toEqual(1); }); @@ -95,15 +96,19 @@ describe('Infinite Scroll', () => { let content: Content; let contentElementRef; let infiniteElementRef; + let ev: ScrollEvent = {}; + let dom: MockDomController; beforeEach(() => { contentElementRef = mockElementRef(); - content = new Content(config, contentElementRef, mockRenderer(), null, null, null, null, null); + content = new Content(config, contentElementRef, mockRenderer(), null, null, null, null, null, dom); content._scrollEle = document.createElement('div'); content._scrollEle.className = 'scroll-content'; infiniteElementRef = mockElementRef(); - inf = new InfiniteScroll(content, mockZone(), infiniteElementRef); + dom = new MockDomController(); + + inf = new InfiniteScroll(content, mockZone(), infiniteElementRef, dom); }); function setInfiniteScrollTop(scrollTop) { diff --git a/src/components/input/input-base.ts b/src/components/input/input-base.ts index 7b563d398b..88e08f75f9 100644 --- a/src/components/input/input-base.ts +++ b/src/components/input/input-base.ts @@ -4,7 +4,8 @@ import { NgControl } from '@angular/forms'; import { App } from '../app/app'; import { copyInputAttributes, PointerCoordinates, hasPointerMoved, pointerCoord } from '../../util/dom'; import { Config } from '../../config/config'; -import { Content, ContentDimensions } from '../content/content'; +import { Content, ContentDimensions, ScrollEvent } from '../content/content'; +import { DomController } from '../../util/dom-controller'; import { Form, IonicFormInput } from '../../util/form'; import { Ion } from '../ion'; import { isTrueProperty } from '../../util/util'; @@ -21,10 +22,8 @@ import { Platform } from '../../platform/platform'; */ export class InputBase extends Ion implements IonicFormInput { _coord: PointerCoordinates; - _deregScroll: Function; _disabled: boolean = false; _keyboardHeight: number; - _scrollMove: EventListener; _type: string = 'text'; _useAssist: boolean; _usePadding: boolean; @@ -35,6 +34,8 @@ export class InputBase extends Ion implements IonicFormInput { _autoCorrect: string; _nav: NavControllerBase; _native: NativeInput; + _scrollStart: any; + _scrollEnd: any; // Whether to clear after the user returns to the input and resumes editing _clearOnEdit: boolean; @@ -51,9 +52,10 @@ export class InputBase extends Ion implements IonicFormInput { protected _platform: Platform, elementRef: ElementRef, renderer: Renderer, - protected _scrollView: Content, + protected _content: Content, nav: NavController, - ngControl: NgControl + ngControl: NgControl, + protected _dom: DomController ) { super(config, elementRef, renderer, 'input'); @@ -72,29 +74,27 @@ export class InputBase extends Ion implements IonicFormInput { } _form.register(this); + + this._scrollStart = _content.ionScrollStart.subscribe((ev: ScrollEvent) => { + this.scrollHideFocus(ev, true); + }); + this._scrollEnd = _content.ionScrollEnd.subscribe((ev: ScrollEvent) => { + this.scrollHideFocus(ev, false); + }); } - scrollMove(ev: UIEvent) { - // scroll move event listener this instance can reuse - console.debug(`input-base, scrollMove`); + scrollHideFocus(ev: ScrollEvent, shouldHideFocus: boolean) { + // do not continue if there's no nav, or it's transitioning + if (!this._nav) return; - if (!(this._nav && this._nav.isTransitioning())) { - this.deregScrollMove(); - - if (this.hasFocus()) { - this._native.hideFocus(true); - - this._scrollView.onScrollEnd(() => { - this._native.hideFocus(false); - - if (this.hasFocus()) { - // if it still has focus then keep listening - this.regScrollMove(); - } - }); - } + // DOM READ: check if this input has focus + if (this.hasFocus()) { + // if it does have focus, then do the dom write + this._dom.write(() => { + this._native.hideFocus(shouldHideFocus); + }); } - }; + } setItemInputControlCss() { let item = this._item; @@ -322,9 +322,6 @@ export class InputBase extends Ion implements IonicFormInput { console.debug(`input-base, focusChange, inputHasFocus: ${inputHasFocus}, ${this._item.getNativeElement().nodeName}.${this._item.getNativeElement().className}`); this._item.setElementClass('input-has-focus', inputHasFocus); } - if (!inputHasFocus) { - this.deregScrollMove(); - } // If clearOnEdit is enabled and the input blurred but has a value, set a flag if (this._clearOnEdit && !inputHasFocus && this.hasValue()) { @@ -379,11 +376,11 @@ export class InputBase extends Ion implements IonicFormInput { */ initFocus() { // begin the process of setting focus to the inner input element - const scrollView = this._scrollView; + const content = this._content; - console.debug(`input-base, initFocus(), scrollView: ${!!scrollView}`); + console.debug(`input-base, initFocus(), scrollView: ${!!content}`); - if (scrollView) { + if (content) { // this input is inside of a scroll view // find out if text input should be manually scrolled into view @@ -391,7 +388,7 @@ export class InputBase extends Ion implements IonicFormInput { let ele: HTMLElement = this._elementRef.nativeElement; ele = ele.closest('ion-item,[ion-item]') || ele; - const scrollData = getScrollData(ele.offsetTop, ele.offsetHeight, scrollView.getContentDimensions(), this._keyboardHeight, this._platform.height()); + const scrollData = getScrollData(ele.offsetTop, ele.offsetHeight, content.getContentDimensions(), this._keyboardHeight, this._platform.height()); if (Math.abs(scrollData.scrollAmount) < 4) { // the text input is in a safe position that doesn't // require it to be scrolled into view, just set focus now @@ -400,17 +397,16 @@ export class InputBase extends Ion implements IonicFormInput { // all good, allow clicks again this._app.setEnabled(true); this._nav && this._nav.setTransitioning(false); - this.regScrollMove(); if (this._usePadding) { - this._scrollView.clearScrollPaddingFocusOut(); + content.clearScrollPaddingFocusOut(); } return; } if (this._usePadding) { // add padding to the bottom of the scroll view (if needed) - scrollView.addScrollPadding(scrollData.scrollPadding); + content.addScrollPadding(scrollData.scrollPadding); } // manually scroll the text input to the top @@ -425,7 +421,7 @@ export class InputBase extends Ion implements IonicFormInput { this._native.beginFocus(true, scrollData.inputSafeY); // scroll the input into place - scrollView.scrollTo(0, scrollData.scrollTo, scrollDuration, () => { + content.scrollTo(0, scrollData.scrollTo, scrollDuration, () => { console.debug(`input-base, scrollTo completed, scrollTo: ${scrollData.scrollTo}, scrollDuration: ${scrollDuration}`); // the scroll view is in the correct position now // give the native text input focus @@ -437,17 +433,15 @@ export class InputBase extends Ion implements IonicFormInput { // all good, allow clicks again this._app.setEnabled(true); this._nav && this._nav.setTransitioning(false); - this.regScrollMove(); if (this._usePadding) { - this._scrollView.clearScrollPaddingFocusOut(); + content.clearScrollPaddingFocusOut(); } }); } else { // not inside of a scroll view, just focus it this.setFocus(); - this.regScrollMove(); } } @@ -482,26 +476,6 @@ export class InputBase extends Ion implements IonicFormInput { */ registerOnTouched(fn: any) { this.onTouched = fn; } - /** - * @private - */ - regScrollMove() { - // register scroll move listener - if (this._useAssist && this._scrollView) { - setTimeout(() => { - this.deregScrollMove(); - this._deregScroll = this._scrollView.addScrollListener(this.scrollMove.bind(this)); - }, 80); - } - } - - /** - * @private - */ - deregScrollMove() { - // deregister the scroll move listener - this._deregScroll && this._deregScroll(); - } focusNext() { this._form.tabFocus(this); diff --git a/src/components/input/input.ts b/src/components/input/input.ts index 375557ac98..9e5892bf90 100644 --- a/src/components/input/input.ts +++ b/src/components/input/input.ts @@ -4,6 +4,7 @@ import { NgControl } from '@angular/forms'; import { App } from '../app/app'; import { Config } from '../../config/config'; import { Content } from '../content/content'; +import { DomController } from '../../util/dom-controller'; import { Form } from '../../util/form'; import { InputBase } from './input-base'; import { isTrueProperty } from '../../util/util'; @@ -91,9 +92,10 @@ export class TextInput extends InputBase { renderer: Renderer, @Optional() scrollView: Content, @Optional() nav: NavController, - @Optional() ngControl: NgControl + @Optional() ngControl: NgControl, + dom: DomController ) { - super(config, form, item, app, platform, elementRef, renderer, scrollView, nav, ngControl); + super(config, form, item, app, platform, elementRef, renderer, scrollView, nav, ngControl, dom); this.mode = config.get('mode'); } @@ -238,6 +240,8 @@ export class TextInput extends InputBase { */ ngOnDestroy() { this._form.deregister(this); + this._scrollStart.unsubscribe(); + this._scrollEnd.unsubscribe(); } /** @@ -314,9 +318,10 @@ export class TextArea extends InputBase { renderer: Renderer, @Optional() scrollView: Content, @Optional() nav: NavController, - @Optional() ngControl: NgControl + @Optional() ngControl: NgControl, + dom: DomController ) { - super(config, form, item, app, platform, elementRef, renderer, scrollView, nav, ngControl); + super(config, form, item, app, platform, elementRef, renderer, scrollView, nav, ngControl, dom); this.mode = config.get('mode'); } @@ -405,6 +410,8 @@ export class TextArea extends InputBase { */ ngOnDestroy() { this._form.deregister(this); + this._scrollStart.unsubscribe(); + this._scrollEnd.unsubscribe(); } /** diff --git a/src/components/item/item-reorder.ts b/src/components/item/item-reorder.ts index 32339df75e..92c97880b6 100644 --- a/src/components/item/item-reorder.ts +++ b/src/components/item/item-reorder.ts @@ -225,7 +225,7 @@ export class ItemReorder { } _scrollContent(scroll: number) { - let scrollTop = this._content.getScrollTop() + scroll; + const scrollTop = this._content.scrollTop + scroll; if (scroll !== 0) { this._content.scrollTo(0, scrollTop, 0); } diff --git a/src/components/refresher/test/refresher.spec.ts b/src/components/refresher/test/refresher.spec.ts index a51e6e6e54..c03429d16e 100644 --- a/src/components/refresher/test/refresher.spec.ts +++ b/src/components/refresher/test/refresher.spec.ts @@ -1,7 +1,7 @@ import { Refresher } from '../refresher'; import { Content } from '../../content/content'; import { GestureController } from '../../../gestures/gesture-controller'; -import { mockConfig, mockElementRef, mockRenderer, mockZone } from '../../../util/mock-providers'; +import { mockConfig, MockDomController, mockElementRef, mockRenderer, mockZone } from '../../../util/mock-providers'; describe('Refresher', () => { @@ -217,12 +217,14 @@ describe('Refresher', () => { let refresher: Refresher; let content: Content; + let dom: MockDomController; beforeEach(() => { let gestureController = new GestureController(null); let elementRef = mockElementRef(); + dom = new MockDomController(); elementRef.nativeElement.children.push(''); - content = new Content(mockConfig(), mockElementRef(), mockRenderer(), null, null, mockZone(), null, null); + content = new Content(mockConfig(), mockElementRef(), mockRenderer(), null, null, mockZone(), null, null, dom); content._scrollEle = document.createElement('div'); content._scrollEle.className = 'scroll-content'; diff --git a/src/directives.ts b/src/directives.ts index 71b28d9f43..492289a8a3 100644 --- a/src/directives.ts +++ b/src/directives.ts @@ -84,7 +84,7 @@ export { Card, CardContent, CardHeader, CardTitle } from './components/card/card export { Checkbox } from './components/checkbox/checkbox'; export { Chip } from './components/chip/chip'; export { ClickBlock } from './util/click-block'; -export { Content } from './components/content/content'; +export { Content, ScrollEvent, ScrollDirection } from './components/content/content'; export { DateTime } from './components/datetime/datetime'; export { FabContainer, FabButton, FabList } from './components/fab/fab'; export { Grid, Row, Col } from './components/grid/grid'; diff --git a/src/util/dom-controller.ts b/src/util/dom-controller.ts index 94e8123c8a..ba4f179425 100644 --- a/src/util/dom-controller.ts +++ b/src/util/dom-controller.ts @@ -95,32 +95,37 @@ export class DomController { } protected flush(timeStamp: number) { + let err; + try { - this.dispatch(timeStamp); - } finally { - this.q = false; - } - } - - private dispatch(timeStamp: number) { - let i: number; - const r = this.r; - const rLen = r.length; - const w = this.w; - const wLen = w.length; - - // ******** DOM READS **************** - for (i = 0; i < rLen; i++) { - r[i](timeStamp); + dispatch(timeStamp, this.r, this.w); + } catch (e) { + err = e; } - // ******** DOM WRITES **************** - for (i = 0; i < wLen; i++) { - w[i](timeStamp); + this.q = false; + + if (this.r.length || this.w.length) { + this.queue(); } - r.length = 0; - w.length = 0; + if (err) { + throw err; + } } } + +function dispatch(timeStamp: number, r: Function[], w: Function[]) { + let task; + + // ******** DOM READS **************** + while (task = r.shift()) { + task(timeStamp); + } + + // ******** DOM WRITES **************** + while (task = w.shift()) { + task(timeStamp); + } +} diff --git a/src/util/events.ts b/src/util/events.ts index 3798bed0d6..a4d64a4edc 100644 --- a/src/util/events.ts +++ b/src/util/events.ts @@ -133,21 +133,22 @@ export function setupEvents(platform: Platform, dom: DomController): Events { let el = document.elementFromPoint(platform.width() / 2, platform.height() / 2); if (!el) { return; } - let content = el.closest('.scroll-content'); - if (content) { - var scroll = new ScrollView(content); + let contentEle = el.closest('.scroll-content'); + if (contentEle) { + var scroll = new ScrollView(dom); + scroll.init(contentEle, 0, 0); // We need to stop scrolling if it's happening and scroll up - content.style['WebkitBackfaceVisibility'] = 'hidden'; - content.style['WebkitTransform'] = 'translate3d(0,0,0)'; + contentEle.style['WebkitBackfaceVisibility'] = 'hidden'; + contentEle.style['WebkitTransform'] = 'translate3d(0,0,0)'; nativeRaf(function() { - content.style.overflow = 'hidden'; + contentEle.style.overflow = 'hidden'; function finish() { - content.style.overflow = ''; - content.style['WebkitBackfaceVisibility'] = ''; - content.style['WebkitTransform'] = ''; + contentEle.style.overflow = ''; + contentEle.style['WebkitBackfaceVisibility'] = ''; + contentEle.style['WebkitTransform'] = ''; } let didScrollTimeout = setTimeout(() => { diff --git a/src/util/keyboard.ts b/src/util/keyboard.ts index 7ac5a6fc54..35756a22a6 100644 --- a/src/util/keyboard.ts +++ b/src/util/keyboard.ts @@ -1,7 +1,8 @@ import { Injectable, NgZone } from '@angular/core'; import { Config } from '../config/config'; -import { focusOutActiveElement, hasFocusedTextInput, nativeRaf, nativeTimeout, zoneRafFrames } from './dom'; +import { DomController } from './dom-controller'; +import { focusOutActiveElement, hasFocusedTextInput, nativeTimeout } from './dom'; import { Key } from './key'; @@ -23,7 +24,7 @@ import { Key } from './key'; @Injectable() export class Keyboard { - constructor(config: Config, private _zone: NgZone) { + constructor(config: Config, private _zone: NgZone, private _dom: DomController) { _zone.runOutsideAngular(() => { this.focusOutline(config.get('focusOutline'), document); @@ -92,10 +93,12 @@ export class Keyboard { function checkKeyboard() { console.debug(`keyboard, isOpen: ${self.isOpen()}`); if (!self.isOpen() || checks > pollingChecksMax) { - zoneRafFrames(30, () => { - console.debug(`keyboard, closed`); - callback(); - }); + nativeTimeout(function() { + self._zone.run(function() { + console.debug(`keyboard, closed`); + callback(); + }); + }, 400); } else { nativeTimeout(checkKeyboard, pollingInternval); @@ -112,11 +115,13 @@ export class Keyboard { * Programmatically close the keyboard. */ close() { - console.debug(`keyboard, close()`); - nativeRaf(() => { + this._dom.read(() => { if (hasFocusedTextInput()) { // only focus out when a text input has focus - focusOutActiveElement(); + console.debug(`keyboard, close()`); + this._dom.write(() => { + focusOutActiveElement(); + }); } }); } @@ -141,7 +146,7 @@ export class Keyboard { let isKeyInputEnabled = false; function cssClass() { - nativeRaf(() => { + this._dom.write(() => { document.body.classList[isKeyInputEnabled ? 'add' : 'remove']('focus-outline'); }); } diff --git a/src/util/mock-providers.ts b/src/util/mock-providers.ts index 075cd0860c..2d152c0eba 100644 --- a/src/util/mock-providers.ts +++ b/src/util/mock-providers.ts @@ -3,13 +3,16 @@ import { Location } from '@angular/common'; import { AnimationOptions } from '../animations/animation'; import { App } from '../components/app/app'; -import { IonicApp } from '../components/app/app-root'; import { Config } from '../config/config'; +import { Content } from '../components/content/content'; import { DeepLinker } from '../navigation/deep-linker'; +import { DomController } from './dom-controller'; import { GestureController } from '../gestures/gesture-controller'; +import { Haptic } from './haptic'; +import { IonicApp } from '../components/app/app-root'; import { Keyboard } from './keyboard'; import { Menu } from '../components/menu/menu'; -import { ViewState, DeepLinkConfig } from '../navigation/nav-util'; +import { NavControllerBase } from '../navigation/nav-controller-base'; import { OverlayPortal } from '../components/nav/overlay-portal'; import { PageTransition } from '../transitions/page-transition'; import { Platform } from '../platform/platform'; @@ -19,10 +22,8 @@ import { Tabs } from '../components/tabs/tabs'; import { TransitionController } from '../transitions/transition-controller'; import { UrlSerializer } from '../navigation/url-serializer'; import { ViewController } from '../navigation/view-controller'; +import { ViewState, DeepLinkConfig } from '../navigation/nav-util'; -import { NavControllerBase } from '../navigation/nav-controller-base'; -import { Haptic } from './haptic'; -import { DomController } from './dom-controller'; export const mockConfig = function(config?: any, url: string = '/', platform?: Platform) { const c = new Config(); @@ -72,6 +73,10 @@ export const mockTrasitionController = function(config: Config) { return trnsCtrl; }; +export const mockContent = function(): Content { + return new Content(mockConfig(), mockElementRef(), mockRenderer(), null, null, mockZone(), null, null, new MockDomController()); +}; + export const mockZone = function(): NgZone { return new NgZone(false); }; @@ -234,7 +239,9 @@ export const mockNavController = function(): NavControllerBase { let zone = mockZone(); - let keyboard = new Keyboard(config, zone); + let dom = new MockDomController(); + + let keyboard = new Keyboard(config, zone, dom); let elementRef = mockElementRef(); @@ -248,8 +255,6 @@ export const mockNavController = function(): NavControllerBase { let trnsCtrl = mockTrasitionController(config); - let dom = new DomController(); - let nav = new NavControllerBase( null, app, @@ -262,7 +267,7 @@ export const mockNavController = function(): NavControllerBase { gestureCtrl, trnsCtrl, linker, - dom, + dom ); nav._viewInit = function(enteringView: ViewController) { @@ -285,7 +290,9 @@ export const mockNavController = function(): NavControllerBase { export const mockOverlayPortal = function(app: App, config: Config, platform: Platform): OverlayPortal { let zone = mockZone(); - let keyboard = new Keyboard(config, zone); + let dom = new MockDomController(); + + let keyboard = new Keyboard(config, zone, dom); let elementRef = mockElementRef(); @@ -301,8 +308,6 @@ export const mockOverlayPortal = function(app: App, config: Config, platform: Pl let deepLinker = new DeepLinker(app, serializer, location); - let dom = new DomController(); - return new OverlayPortal( app, config, @@ -328,7 +333,9 @@ export const mockTab = function(parentTabs: Tabs): Tab { let zone = mockZone(); - let keyboard = new Keyboard(config, zone); + let dom = new MockDomController(); + + let keyboard = new Keyboard(config, zone, dom); let elementRef = mockElementRef(); @@ -342,8 +349,6 @@ export const mockTab = function(parentTabs: Tabs): Tab { let linker = mockDeepLinker(null, app); - let dom = new DomController(); - let tab = new Tab( parentTabs, app, @@ -402,6 +407,22 @@ export const mockHaptic = function (): Haptic { return new Haptic(null); }; + + +export class MockDomController extends DomController { + private timeStamp = 0; + + protected queue() {} + + flush(done: any) { + setTimeout(() => { + const timeStamp = ++this.timeStamp; + super.flush(timeStamp); + done(timeStamp); + }, 0); + } +} + export class MockView {} export class MockView1 {} export class MockView2 {} diff --git a/src/util/scroll-view.ts b/src/util/scroll-view.ts index 0802abe4d5..27a63b59b5 100644 --- a/src/util/scroll-view.ts +++ b/src/util/scroll-view.ts @@ -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; - private _velocity: number; - private _max: number; - private _rafId: number; - private _cb: Function; - isPlaying: boolean; + isScrolling = false; + scrollStart = new Subject(); + scroll = new Subject(); + scrollEnd = new Subject(); - 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) { - (this._el.style)[CSS.transform] = `translate3d(0px,${top * -1}px,0px)`; + (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) { + (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 { // 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; diff --git a/src/util/ui-event-manager.ts b/src/util/ui-event-manager.ts index db4795943e..d47ca1397a 100644 --- a/src/util/ui-event-manager.ts +++ b/src/util/ui-event-manager.ts @@ -191,14 +191,14 @@ export class UIEventManager { return; } let zone = config.zone || this.zoneWrapped; - let opts; + let opts: any; if (supportsOptions) { opts = {}; if (config.passive === true) { - opts['passive'] = true; + opts.passive = true; } if (config.capture === true) { - opts['capture'] = true; + opts.capture = true; } } else { if (config.passive === true) { @@ -244,13 +244,13 @@ export class UIEventManager { } export function listenEvent(ele: any, eventName: string, zoneWrapped: boolean, option: any, callback: any): Function { - let rawEvent = (!zoneWrapped && '__zone_symbol__addEventListener' in ele); + const rawEvent = (!zoneWrapped && !!ele.__zone_symbol__addEventListener); if (rawEvent) { ele.__zone_symbol__addEventListener(eventName, callback, option); - assert('__zone_symbol__removeEventListener' in ele, 'native removeEventListener does not exist'); + assert(!!ele.__zone_symbol__removeEventListener, 'native removeEventListener does not exist'); return () => ele.__zone_symbol__removeEventListener(eventName, callback, option); - } else { - ele.addEventListener(eventName, callback, option); - return () => ele.removeEventListener(eventName, callback, option); } + + ele.addEventListener(eventName, callback, option); + return () => ele.removeEventListener(eventName, callback, option); }