diff options
-rw-r--r-- | js/views/virtuallist.js | 304 |
1 files changed, 298 insertions, 6 deletions
diff --git a/js/views/virtuallist.js b/js/views/virtuallist.js index 0c5a0ff7b..84ceae4f0 100644 --- a/js/views/virtuallist.js +++ b/js/views/virtuallist.js @@ -68,7 +68,9 @@ * exceed the bottom position of its next element. * * It is assumed that the position and size of an element will not change - * once added to the list. + * once added to the list. Changing the size of the container could change + * the position and size of all the elements, so in that case "reload()" + * needs to be called. * * * @@ -80,16 +82,21 @@ * * ············· - List start / Wrapper background start - Top position = 0 * · · + * · _ _ _ · _ First loaded element + * · · * · _ _ _ _ _ · _ Wrapper start - Top position ~= scroll position * :___________: _ * | ~~~ | | Viewport start / Container top * | ~~ ||| - * | ~~ | | + * | ~~ ||| * | ~~~~~ | | Viewport end / Container bottom * :¯¯¯¯¯¯¯¯¯¯¯: ¯ * · ¯ ¯ ¯ ¯ ¯ · ¯ Wrapper end * · · * · · + * · ¯ ¯ ¯ · ¯ Last loaded element + * · · + * · · * · · * · · * ············· - List end / Wrapper background end @@ -136,12 +143,23 @@ * this temporal wrapper is set based on the already added elements, so the * browser can layout the new elements and their real position and size can * be cached. + * + * Reloading the list recalculates the position and size of all the + * elements. When the list contains a lot of elements it is not possible to + * recalculate the values for all the elements at once, so they are first + * recalculated for the visible elements and then they are progressively + * recalculated for the rest of elements. During that process it is possible + * to scroll only to the already loaded elements (although eventually all + * the elements will be loaded and it will be possible to scroll again to + * any element). */ var VirtualList = function($container) { this._$container = $container; this._$firstElement = null; this._$lastElement = null; + this._$firstLoadedElement = null; + this._$lastLoadedElement = null; this._$firstVisibleElement = null; this._$lastVisibleElement = null; @@ -219,6 +237,23 @@ }, prependElementEnd: function() { + // If the prepended elements are not immediately before the first + // loaded element there is nothing to load now; they will be loaded + // as needed with the other pending elements. + if (this._$firstPrependedElement._next !== this._$firstLoadedElement) { + delete this._prependedElementsBuffer; + + return; + } + + if (this._lastContainerWidth !== this._$container.width()) { + delete this._prependedElementsBuffer; + + this.reload(); + + return; + } + this._loadPreviousElements( this._$firstPrependedElement, this._$lastPrependedElement, @@ -231,6 +266,23 @@ }, appendElementEnd: function() { + // If the appended elements are not immediately after the last + // loaded element there is nothing to load now; they will be loaded + // as needed with the other pending elements. + if (this._$firstAppendedElement._previous !== this._$lastLoadedElement) { + delete this._appendedElementsBuffer; + + return; + } + + if (this._lastContainerWidth !== this._$container.width()) { + delete this._appendedElementsBuffer; + + this.reload(); + + return; + } + this._loadNextElements( this._$firstAppendedElement, this._$lastAppendedElement, @@ -242,6 +294,232 @@ this.updateVisibleElements(); }, + /** + * Reloads the list to adjust to the new size of the container. + * + * This needs to be called whenever the size of the container has + * changed. + * + * When the width of the container has changed it is not possible to + * guarantee that exactly the same elements that were visible before + * will be visible after the list is reloaded. Due to this, in those + * cases reloading the list just ensures that the last element that was + * partially visible before will be fully visible after the list is + * reloaded. + * + * On the other hand, when only the height has changed no reload is + * needed; in that case the visibility of the elements is updated based + * on the new height. + * + * Reloading the list requires to recalculate the position and size of + * all the elements. The initial call reloads the last visible element + * (if any) and some of its previous and next siblings; the rest of the + * elements will be queued to be progressively updated until all are + * loaded. During this process it is possible to scroll only to those + * elements already loaded, although further elements can be appended or + * prepended if needed and they will be available once the reload ends. + * + * In browsers with subpixel accuracy for the position and size that use + * integer values for the scroll position, like Firefox, reloading the + * list causes a wiggly effect (and, in some cases, a slight drift) due + * to prepending the elements and trying to keep the scroll position, as + * the scroll position is rounded to an int but the position of the + * elements is a float. + */ + reload: function() { + if (this._lastContainerWidth === this._$container.width()) { + // If the width is the same the cache is still valid, so no need + // for a full reload. + this.updateVisibleElements(); + + return; + } + + if (this._pendingLoad) { + clearTimeout(this._pendingLoad); + delete this._pendingLoad; + } + + this._lastContainerWidth = this._$container.width(); + + var $initialElement = this._$lastVisibleElement; + if (!$initialElement) { + // No element was visible; either the list was reloaded when + // empty or during the first append/prepend of elements. + $initialElement = this._$lastElement; + } + + if (!$initialElement) { + // The list is empty, so there is nothing to load. + return; + } + + // Detach all the visible elements from the wrapper + this._$wrapper.detach(); + + while (this._$firstVisibleElement && this._$firstVisibleElement !== this._$lastVisibleElement._next) { + this._$firstVisibleElement.detach(); + this._$firstVisibleElement = this._$firstVisibleElement._next; + } + + this._$firstVisibleElement = null; + this._$lastVisibleElement = null; + + this._$wrapper._top = 0; + this._$wrapper.css('top', this._$wrapper._top); + + this._$wrapper.appendTo(this._$container); + + // Reset wrapper background + this._$wrapperBackground.height(0); + + this._loadInitialElements($initialElement); + + // Scroll to the last visible element, or to the top of the next one + // to prevent it from becoming the last visible element when the + // visibilities are updated. + if ($initialElement._next) { + // The implicit "Math.floor()" on the scroll position when the + // browser has subpixel accuracy but uses int positions for + // scrolling ensures that the next element to the last visible + // one will not become visible (which could happen if the value + // was rounded instead). + this._$container.scrollTop($initialElement._next._top - this._getElementOuterHeightWithoutMargins(this._$container)); + } else { + // As the last visible element is also the last element this + // simply scrolls the list to the bottom. + this._$container.scrollTop($initialElement._top + $initialElement._height); + } + + this.updateVisibleElements(); + + this._queueLoadOfPendingElements(); + }, + + _loadInitialElements: function($initialElement) { + var $firstElement = $initialElement; + var $lastElement = $firstElement; + + var elementsBuffer = document.createDocumentFragment(); + + var $currentElement = $firstElement; + var i; + for (i = 0; i < 50 && $currentElement; i++) { + // ParentNode.prepend() is not compatible with older browsers. + elementsBuffer.insertBefore($currentElement.get(0), elementsBuffer.firstChild); + $lastElement = $currentElement; + $currentElement = $currentElement._previous; + } + + $currentElement = $firstElement._next; + for (i = 0; i < 50 && $currentElement; i++) { + // ParentNode.append() is not compatible with older browsers. + elementsBuffer.appendChild($currentElement.get(0)); + $firstElement = $currentElement; + $currentElement = $currentElement._next; + } + + this._$firstLoadedElement = null; + this._$lastLoadedElement = null; + + this._loadPreviousElements( + $firstElement, + $lastElement, + elementsBuffer + ); + + // FIXME it is happily assumed that the initial load covers the full + // view with 50 and 50 elements before and after... but it should be + // actually verified and enforced loading again other elements as + // needed. + }, + + _queueLoadOfPendingElements: function() { + if (this._pendingLoad) { + return; + } + + // To load the elements they need to be rendered again, so it is a + // rather costly operation. A small interval between loads, even + // with just a few elements, could hog the browser and cause its UI + // to become unresponsive, so a "long" interval is used instead; to + // compensate for the "long" interval the number of elements loaded + // in each batch is rather large, but still within a reasonable + // limit that should be renderable by the browser without causing + // (much :-) ) jank. + this._pendingLoad = setTimeout(function() { + delete this._pendingLoad; + + var numberOfElementsToLoad = 200; + numberOfElementsToLoad -= this._loadPreviousPendingElements(numberOfElementsToLoad/2); + this._loadNextPendingElements(numberOfElementsToLoad); + + // The loaded elements are out of view (it is assumed that the + // initial load of elements cover the full visible area), so no + // need to update the visible elements. + }.bind(this), 100); + }, + + _loadPreviousPendingElements: function(numberOfElementsToLoad) { + if (!this._$firstLoadedElement || this._$firstLoadedElement === this._$firstElement) { + return 0; + } + + var prependedElementsBuffer = document.createDocumentFragment(); + + var $firstPrependedElement = this._$firstLoadedElement._previous; + var $lastPrependedElement = $firstPrependedElement; + + var $currentElement = $firstPrependedElement; + var i; + for (i = 0; i < numberOfElementsToLoad && $currentElement; i++) { + // ParentNode.prepend() is not compatible with older browsers. + prependedElementsBuffer.insertBefore($currentElement.get(0), prependedElementsBuffer.firstChild); + $lastPrependedElement = $currentElement; + $currentElement = $currentElement._previous; + } + + this._loadPreviousElements( + $firstPrependedElement, + $lastPrependedElement, + prependedElementsBuffer + ); + + this._queueLoadOfPendingElements(); + + return i; + }, + + _loadNextPendingElements: function(numberOfElementsToLoad) { + if (!this._$lastLoadedElement || this._$lastLoadedElement === this._$lastElement) { + return 0; + } + + var appendedElementsBuffer = document.createDocumentFragment(); + + var $firstAppendedElement = this._$lastLoadedElement._next; + var $lastAppendedElement = $firstAppendedElement; + + var $currentElement = $firstAppendedElement; + var i; + for (i = 0; i < numberOfElementsToLoad && $currentElement; i++) { + // ParentNode.append() is not compatible with older browsers. + appendedElementsBuffer.appendChild($currentElement.get(0)); + $lastAppendedElement = $currentElement; + $currentElement = $currentElement._next; + } + + this._loadNextElements( + $firstAppendedElement, + $lastAppendedElement, + appendedElementsBuffer + ); + + this._queueLoadOfPendingElements(); + + return i; + }, + _loadPreviousElements: function($firstElementToLoad, $lastElementToLoad, elementsBuffer) { var $wrapper = $('<div class="wrapper"></div>'); $wrapper._top = 0; @@ -270,6 +548,13 @@ // number. this._$wrapperBackground.height(this._getElementHeight(this._$wrapperBackground) + wrapperHeightDifference); + // Note that the order of "first/last" is not the same for the main + // elements and the elements passed to this method. + if (!this._$lastLoadedElement) { + this._$lastLoadedElement = $firstElementToLoad; + } + this._$firstLoadedElement = $lastElementToLoad; + while ($firstElementToLoad !== $lastElementToLoad._previous) { this._updateCache($firstElementToLoad, $wrapper); @@ -283,7 +568,7 @@ $wrapper.remove(); // Update the cached position of elements after the prepended ones. - while ($firstExistingElement) { + while ($firstExistingElement !== this._$lastLoadedElement._next) { $firstExistingElement._top += wrapperHeightDifference; $firstExistingElement._topRaw += wrapperHeightDifference; @@ -344,6 +629,11 @@ // number. this._$wrapperBackground.height(this._getElementHeight(this._$wrapperBackground) + wrapperHeightDifference); + if (!this._$firstLoadedElement) { + this._$firstLoadedElement = $firstElementToLoad; + } + this._$lastLoadedElement = $lastElementToLoad; + while ($firstElementToLoad !== $lastElementToLoad._next) { this._updateCache($firstElementToLoad, $wrapper); @@ -493,12 +783,12 @@ * of a pixel would be wrongly shown or hidden. */ updateVisibleElements: function() { - if (!this._$firstVisibleElement && !this._$firstElement) { + if (!this._$firstVisibleElement && !this._$firstLoadedElement) { return; } if (!this._$firstVisibleElement) { - this._$firstVisibleElement = this._$firstElement; + this._$firstVisibleElement = this._$firstLoadedElement; this._$lastVisibleElement = this._$firstVisibleElement; this._$wrapper.append(this._$firstVisibleElement); @@ -548,7 +838,7 @@ } // Show the new first visible element. - this._$firstVisibleElement = this._$firstElement; + this._$firstVisibleElement = this._$firstLoadedElement; while (this._$firstVisibleElement._top + this._$firstVisibleElement._height <= visibleAreaTop) { this._$firstVisibleElement = this._$firstVisibleElement._next; } @@ -566,6 +856,7 @@ // Prepend leading elements now visible. while (this._$firstVisibleElement._previous && + this._$firstVisibleElement._previous !== this._$firstLoadedElement._previous && this._$firstVisibleElement._previous._top + this._$firstVisibleElement._previous._height > visibleAreaTop) { this._$firstVisibleElement._previous.prependTo(this._$wrapper); this._$firstVisibleElement = this._$firstVisibleElement._previous; @@ -584,6 +875,7 @@ // Append trailing elements now visible. while (this._$lastVisibleElement._next && + this._$lastVisibleElement._next !== this._$lastLoadedElement._next && this._$lastVisibleElement._next._top < visibleAreaBottom) { this._$lastVisibleElement._next.appendTo(this._$wrapper); this._$lastVisibleElement = this._$lastVisibleElement._next; |