package.src.iron-list-core.js Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of component-base Show documentation
Show all versions of component-base Show documentation
Vaadin component base mixins
/**
* @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}
*/
_physicalItems: null,
/**
* An array of heights for each item in `_physicalItems`
* @type {?Array}
*/
_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=} itemSet
* @param {!Array=} 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=} 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]);
},
};