UNPKG

@vaadin/component-base

Version:

Vaadin component base mixins

736 lines (666 loc) 22 kB
/** * @license * Copyright (c) 2016 The Polymer Project Authors. All rights reserved. * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt * Code distributed by Google as part of the polymer project is also * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt */ import { animationFrame, idlePeriod, microTask } from './async.js'; import { Debouncer, enqueueDebouncer, flush } from './debounce.js'; const IOS = navigator.userAgent.match(/iP(?:hone|ad;(?: U;)? CPU) OS (\d+)/u); const IOS_TOUCH_SCROLLING = IOS && IOS[1] >= 8; const DEFAULT_PHYSICAL_COUNT = 3; /** * DO NOT EDIT THIS FILE! * * This file includes the iron-list scrolling engine copied from * https://github.com/PolymerElements/iron-list/blob/master/iron-list.js * * If something in the scrolling engine needs to be changed * for the virtualizer's purposes, override a function * in virtualizer-iron-list-adapter.js instead of changing it here. * If a function on this file is no longer needed, the code can be safely deleted. * * This will allow us to keep the iron-list code here as close to * the original as possible. */ export const ironList = { /** * The ratio of hidden tiles that should remain in the scroll direction. * Recommended value ~0.5, so it will distribute tiles evenly in both * directions. */ _ratio: 0.5, /** * The padding-top value for the list. */ _scrollerPaddingTop: 0, /** * This value is a cached value of `scrollTop` from the last `scroll` event. */ _scrollPosition: 0, /** * The sum of the heights of all the tiles in the DOM. */ _physicalSize: 0, /** * The average `offsetHeight` of the tiles observed till now. */ _physicalAverage: 0, /** * The number of tiles which `offsetHeight` > 0 observed until now. */ _physicalAverageCount: 0, /** * The Y position of the item rendered in the `_physicalStart` * tile relative to the scrolling list. */ _physicalTop: 0, /** * The number of items in the list. */ _virtualCount: 0, /** * The estimated scroll height based on `_physicalAverage` */ _estScrollHeight: 0, /** * The scroll height of the dom node */ _scrollHeight: 0, /** * The height of the list. This is referred as the viewport in the context of * list. */ _viewportHeight: 0, /** * The width of the list. This is referred as the viewport in the context of * list. */ _viewportWidth: 0, /** * An array of DOM nodes that are currently in the tree * @type {?Array<!HTMLElement>} */ _physicalItems: null, /** * An array of heights for each item in `_physicalItems` * @type {?Array<number>} */ _physicalSizes: null, /** * A cached value for the first visible index. * See `firstVisibleIndex` * @type {?number} */ _firstVisibleIndexVal: null, /** * A cached value for the last visible index. * See `lastVisibleIndex` * @type {?number} */ _lastVisibleIndexVal: null, /** * The max number of pages to render. One page is equivalent to the height of * the list. */ _maxPages: 2, /** * The cost of stamping a template in ms. */ _templateCost: 0, /** * The bottom of the physical content. */ get _physicalBottom() { return this._physicalTop + this._physicalSize; }, /** * The bottom of the scroll. */ get _scrollBottom() { return this._scrollPosition + this._viewportHeight; }, /** * The n-th item rendered in the last physical item. */ get _virtualEnd() { return this._virtualStart + this._physicalCount - 1; }, /** * The height of the physical content that isn't on the screen. */ get _hiddenContentSize() { return this._physicalSize - this._viewportHeight; }, /** * The maximum scroll top value. */ get _maxScrollTop() { return this._estScrollHeight - this._viewportHeight + this._scrollOffset; }, /** * The largest n-th value for an item such that it can be rendered in * `_physicalStart`. */ get _maxVirtualStart() { const virtualCount = this._virtualCount; return Math.max(0, virtualCount - this._physicalCount); }, get _virtualStart() { return this._virtualStartVal || 0; }, set _virtualStart(val) { val = this._clamp(val, 0, this._maxVirtualStart); this._virtualStartVal = val; }, get _physicalStart() { return this._physicalStartVal || 0; }, /** * The k-th tile that is at the top of the scrolling list. */ set _physicalStart(val) { val %= this._physicalCount; if (val < 0) { val = this._physicalCount + val; } this._physicalStartVal = val; }, /** * The k-th tile that is at the bottom of the scrolling list. */ get _physicalEnd() { return (this._physicalStart + this._physicalCount - 1) % this._physicalCount; }, get _physicalCount() { return this._physicalCountVal || 0; }, set _physicalCount(val) { this._physicalCountVal = val; }, /** * An optimal physical size such that we will have enough physical items * to fill up the viewport and recycle when the user scrolls. * * This default value assumes that we will at least have the equivalent * to a viewport of physical items above and below the user's viewport. */ get _optPhysicalSize() { return this._viewportHeight === 0 ? Infinity : this._viewportHeight * this._maxPages; }, /** * True if the current list is visible. */ get _isVisible() { return Boolean(this.offsetWidth || this.offsetHeight); }, /** * Gets the index of the first visible item in the viewport. * * @type {number} */ get firstVisibleIndex() { let idx = this._firstVisibleIndexVal; if (idx == null) { let physicalOffset = this._physicalTop + this._scrollOffset; idx = this._iterateItems((pidx, vidx) => { physicalOffset += this._getPhysicalSizeIncrement(pidx); if (physicalOffset > this._scrollPosition) { return vidx; } }) || 0; this._firstVisibleIndexVal = idx; } return idx; }, /** * Gets the index of the last visible item in the viewport. * * @type {number} */ get lastVisibleIndex() { let idx = this._lastVisibleIndexVal; if (idx == null) { let physicalOffset = this._physicalTop + this._scrollOffset; this._iterateItems((pidx, vidx) => { if (physicalOffset < this._scrollBottom) { idx = vidx; } physicalOffset += this._getPhysicalSizeIncrement(pidx); }); this._lastVisibleIndexVal = idx; } return idx; }, get _scrollOffset() { return this._scrollerPaddingTop + this.scrollOffset; }, /** * Recycles the physical items when needed. */ _scrollHandler() { const scrollTop = Math.max(0, Math.min(this._maxScrollTop, this._scrollTop)); let delta = scrollTop - this._scrollPosition; const isScrollingDown = delta >= 0; // Track the current scroll position. this._scrollPosition = scrollTop; // Clear indexes for first and last visible indexes. this._firstVisibleIndexVal = null; this._lastVisibleIndexVal = null; // Random access. if (Math.abs(delta) > this._physicalSize && this._physicalSize > 0) { delta -= this._scrollOffset; const idxAdjustment = Math.round(delta / this._physicalAverage); this._virtualStart += idxAdjustment; this._physicalStart += idxAdjustment; // Estimate new physical offset based on the virtual start index. // adjusts the physical start position to stay in sync with the clamped // virtual start index. It's critical not to let this value be // more than the scroll position however, since that would result in // the physical items not covering the viewport, and leading to // _increasePoolIfNeeded to run away creating items to try to fill it. this._physicalTop = Math.min(Math.floor(this._virtualStart) * this._physicalAverage, this._scrollPosition); this._update(); } else if (this._physicalCount > 0) { const reusables = this._getReusables(isScrollingDown); if (isScrollingDown) { this._physicalTop = reusables.physicalTop; this._virtualStart += reusables.indexes.length; this._physicalStart += reusables.indexes.length; } else { this._virtualStart -= reusables.indexes.length; this._physicalStart -= reusables.indexes.length; } this._update(reusables.indexes, isScrollingDown ? null : reusables.indexes); this._debounce('_increasePoolIfNeeded', this._increasePoolIfNeeded.bind(this, 0), microTask); } }, /** * Returns an object that contains the indexes of the physical items * that might be reused and the physicalTop. * * @param {boolean} fromTop If the potential reusable items are above the scrolling region. */ _getReusables(fromTop) { let ith, offsetContent, physicalItemHeight; const idxs = []; const protectedOffsetContent = this._hiddenContentSize * this._ratio; const virtualStart = this._virtualStart; const virtualEnd = this._virtualEnd; const physicalCount = this._physicalCount; let top = this._physicalTop + this._scrollOffset; const bottom = this._physicalBottom + this._scrollOffset; // This may be called outside of a scrollHandler, so use last cached position const scrollTop = this._scrollPosition; const scrollBottom = this._scrollBottom; if (fromTop) { ith = this._physicalStart; offsetContent = scrollTop - top; } else { ith = this._physicalEnd; offsetContent = bottom - scrollBottom; } // eslint-disable-next-line no-constant-condition while (true) { physicalItemHeight = this._getPhysicalSizeIncrement(ith); offsetContent -= physicalItemHeight; if (idxs.length >= physicalCount || offsetContent <= protectedOffsetContent) { break; } if (fromTop) { // Check that index is within the valid range. if (virtualEnd + idxs.length + 1 >= this._virtualCount) { break; } // Check that the index is not visible. if (top + physicalItemHeight >= scrollTop - this._scrollOffset) { break; } idxs.push(ith); top += physicalItemHeight; ith = (ith + 1) % physicalCount; } else { // Check that index is within the valid range. if (virtualStart - idxs.length <= 0) { break; } // Check that the index is not visible. if (top + this._physicalSize - physicalItemHeight <= scrollBottom) { break; } idxs.push(ith); top -= physicalItemHeight; ith = ith === 0 ? physicalCount - 1 : ith - 1; } } return { indexes: idxs, physicalTop: top - this._scrollOffset }; }, /** * Update the list of items, starting from the `_virtualStart` item. * @param {!Array<number>=} itemSet * @param {!Array<number>=} movingUp */ _update(itemSet, movingUp) { if ((itemSet && itemSet.length === 0) || this._physicalCount === 0) { return; } this._assignModels(itemSet); this._updateMetrics(itemSet); // Adjust offset after measuring. if (movingUp) { while (movingUp.length) { const idx = movingUp.pop(); this._physicalTop -= this._getPhysicalSizeIncrement(idx); } } this._positionItems(); this._updateScrollerSize(); }, _isClientFull() { return ( this._scrollBottom !== 0 && this._physicalBottom - 1 >= this._scrollBottom && this._physicalTop <= this._scrollPosition ); }, /** * Increases the pool size. */ _increasePoolIfNeeded(count) { const nextPhysicalCount = this._clamp( this._physicalCount + count, DEFAULT_PHYSICAL_COUNT, this._virtualCount - this._virtualStart, ); const delta = nextPhysicalCount - this._physicalCount; let nextIncrease = Math.round(this._physicalCount * 0.5); if (delta < 0) { return; } if (delta > 0) { const ts = window.performance.now(); // Concat arrays in place. [].push.apply(this._physicalItems, this._createPool(delta)); // Push 0s into physicalSizes. Can't use Array.fill because IE11 doesn't // support it. for (let i = 0; i < delta; i++) { this._physicalSizes.push(0); } this._physicalCount += delta; // Update the physical start if it needs to preserve the model of the // focused item. In this situation, the focused item is currently rendered // and its model would have changed after increasing the pool if the // physical start remained unchanged. if ( this._physicalStart > this._physicalEnd && this._isIndexRendered(this._focusedVirtualIndex) && this._getPhysicalIndex(this._focusedVirtualIndex) < this._physicalEnd ) { this._physicalStart += delta; } this._update(); this._templateCost = (window.performance.now() - ts) / delta; nextIncrease = Math.round(this._physicalCount * 0.5); } if (this._virtualEnd >= this._virtualCount - 1 || nextIncrease === 0) { // Do nothing. } else if (!this._isClientFull()) { this._debounce('_increasePoolIfNeeded', this._increasePoolIfNeeded.bind(this, nextIncrease), microTask); } else if (this._physicalSize < this._optPhysicalSize) { // Yield and increase the pool during idle time until the physical size is // optimal. this._debounce( '_increasePoolIfNeeded', this._increasePoolIfNeeded.bind(this, this._clamp(Math.round(50 / this._templateCost), 1, nextIncrease)), idlePeriod, ); } }, /** * Renders the a new list. */ _render() { if (!this.isAttached || !this._isVisible) { return; } if (this._physicalCount !== 0) { const reusables = this._getReusables(true); this._physicalTop = reusables.physicalTop; this._virtualStart += reusables.indexes.length; this._physicalStart += reusables.indexes.length; this._update(reusables.indexes); this._update(); this._increasePoolIfNeeded(0); } else if (this._virtualCount > 0) { // Initial render this.updateViewportBoundaries(); this._increasePoolIfNeeded(DEFAULT_PHYSICAL_COUNT); } }, /** * Called when the items have changed. That is, reassignments * to `items`, splices or updates to a single item. */ _itemsChanged(change) { if (change.path === 'items') { this._virtualStart = 0; this._physicalTop = 0; this._virtualCount = this.items ? this.items.length : 0; this._physicalIndexForKey = {}; this._firstVisibleIndexVal = null; this._lastVisibleIndexVal = null; if (!this._physicalItems) { this._physicalItems = []; } if (!this._physicalSizes) { this._physicalSizes = []; } this._physicalStart = 0; if (this._scrollTop > this._scrollOffset) { this._resetScrollPosition(0); } this._debounce('_render', this._render, animationFrame); } }, /** * Executes a provided function per every physical index in `itemSet` * `itemSet` default value is equivalent to the entire set of physical * indexes. * * @param {!function(number, number)} fn * @param {!Array<number>=} itemSet */ _iterateItems(fn, itemSet) { let pidx, vidx, rtn, i; if (arguments.length === 2 && itemSet) { for (i = 0; i < itemSet.length; i++) { pidx = itemSet[i]; vidx = this._computeVidx(pidx); if ((rtn = fn.call(this, pidx, vidx)) != null) { return rtn; } } } else { pidx = this._physicalStart; vidx = this._virtualStart; for (; pidx < this._physicalCount; pidx++, vidx++) { if ((rtn = fn.call(this, pidx, vidx)) != null) { return rtn; } } for (pidx = 0; pidx < this._physicalStart; pidx++, vidx++) { if ((rtn = fn.call(this, pidx, vidx)) != null) { return rtn; } } } }, /** * Returns the virtual index for a given physical index * * @param {number} pidx Physical index * @return {number} */ _computeVidx(pidx) { if (pidx >= this._physicalStart) { return this._virtualStart + (pidx - this._physicalStart); } return this._virtualStart + (this._physicalCount - this._physicalStart) + pidx; }, /** * Updates the position of the physical items. */ _positionItems() { this._adjustScrollPosition(); let y = this._physicalTop; this._iterateItems((pidx) => { this.translate3d(0, `${y}px`, 0, this._physicalItems[pidx]); y += this._physicalSizes[pidx]; }); }, _getPhysicalSizeIncrement(pidx) { return this._physicalSizes[pidx]; }, /** * Adjusts the scroll position when it was overestimated. */ _adjustScrollPosition() { const deltaHeight = this._virtualStart === 0 ? this._physicalTop : Math.min(this._scrollPosition + this._physicalTop, 0); // Note: the delta can be positive or negative. if (deltaHeight !== 0) { this._physicalTop -= deltaHeight; // This may be called outside of a scrollHandler, so use last cached position const scrollTop = this._scrollPosition; // Juking scroll position during interial scrolling on iOS is no bueno if (!IOS_TOUCH_SCROLLING && scrollTop > 0) { this._resetScrollPosition(scrollTop - deltaHeight); } } }, /** * Sets the position of the scroll. */ _resetScrollPosition(pos) { if (this.scrollTarget && pos >= 0) { this._scrollTop = pos; this._scrollPosition = this._scrollTop; } }, /** * Sets the scroll height, that's the height of the content, * * @param {boolean=} forceUpdate If true, updates the height no matter what. */ _updateScrollerSize(forceUpdate) { const estScrollHeight = this._physicalBottom + Math.max(this._virtualCount - this._physicalCount - this._virtualStart, 0) * this._physicalAverage; this._estScrollHeight = estScrollHeight; // Amortize height adjustment, so it won't trigger large repaints too often. if ( forceUpdate || this._scrollHeight === 0 || this._scrollPosition >= estScrollHeight - this._physicalSize || Math.abs(estScrollHeight - this._scrollHeight) >= this._viewportHeight ) { this.$.items.style.height = `${estScrollHeight}px`; this._scrollHeight = estScrollHeight; } }, /** * Scroll to a specific index in the virtual list regardless * of the physical items in the DOM tree. * * @method scrollToIndex * @param {number} idx The index of the item */ scrollToIndex(idx) { if (typeof idx !== 'number' || idx < 0 || idx > this.items.length - 1) { return; } flush(); // Items should have been rendered prior scrolling to an index. if (this._physicalCount === 0) { return; } idx = this._clamp(idx, 0, this._virtualCount - 1); // Update the virtual start only when needed. if (!this._isIndexRendered(idx) || idx >= this._maxVirtualStart) { this._virtualStart = idx - 1; } this._assignModels(); this._updateMetrics(); // Estimate new physical offset. this._physicalTop = this._virtualStart * this._physicalAverage; let currentTopItem = this._physicalStart; let currentVirtualItem = this._virtualStart; let targetOffsetTop = 0; const hiddenContentSize = this._hiddenContentSize; // Scroll to the item as much as we can. while (currentVirtualItem < idx && targetOffsetTop <= hiddenContentSize) { targetOffsetTop += this._getPhysicalSizeIncrement(currentTopItem); currentTopItem = (currentTopItem + 1) % this._physicalCount; currentVirtualItem += 1; } this._updateScrollerSize(true); this._positionItems(); this._resetScrollPosition(this._physicalTop + this._scrollOffset + targetOffsetTop); this._increasePoolIfNeeded(0); // Clear cached visible index. this._firstVisibleIndexVal = null; this._lastVisibleIndexVal = null; }, /** * Reset the physical average and the average count. */ _resetAverage() { this._physicalAverage = 0; this._physicalAverageCount = 0; }, /** * A handler for the `iron-resize` event triggered by `IronResizableBehavior` * when the element is resized. */ _resizeHandler() { this._debounce( '_render', () => { // Clear cached visible index. this._firstVisibleIndexVal = null; this._lastVisibleIndexVal = null; if (this._isVisible) { this.updateViewportBoundaries(); // Reinstall the scroll event listener. this.toggleScrollListener(true); this._resetAverage(); this._render(); } else { // Uninstall the scroll event listener. this.toggleScrollListener(false); } }, animationFrame, ); }, _isIndexRendered(idx) { return idx >= this._virtualStart && idx <= this._virtualEnd; }, _getPhysicalIndex(vidx) { return (this._physicalStart + (vidx - this._virtualStart)) % this._physicalCount; }, _clamp(v, min, max) { return Math.min(max, Math.max(min, v)); }, _debounce(name, cb, asyncModule) { if (!this._debouncers) { this._debouncers = {}; } this._debouncers[name] = Debouncer.debounce(this._debouncers[name], asyncModule, cb.bind(this)); enqueueDebouncer(this._debouncers[name]); }, };