UNPKG

qtt-recyclerview

Version:

[![npm](https://img.shields.io/npm/v/vue-recyclerview.svg)](https://www.npmjs.com/package/vue-recyclerview)

613 lines (546 loc) 19.9 kB
/* eslint-disable*/ /** * Copyright 2015 Google Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * Author surma https://github.com/surma * Modified by Awe @hilongjw */ import { inView } from './util' const MAX_COUNT = Infinity /** * Construct an infinite scroller. * @param {Element} scroller The scrollable element to use as the infinite * scroll region. * @param {InfiniteScrollerSource} source A provider of the content to be * displayed in the infinite scroll region. */ export default function InfiniteScroller (scroller, source, options) { // Number of items to instantiate beyond current view in the opposite direction. this.RUNWAY_ITEMS = options.prerender // Number of items to instantiate beyond current view in the opposite direction. this.RUNWAY_ITEMS_OPPOSITE = options.remain // The number of pixels of additional length to allow scrolling to. // this.SCROLL_RUNWAY = options.SCROLL_RUNWAY || SCROLL_RUNWAY // The animation interval (in ms) for fading in content from tombstones. this.ANIMATION_DURATION_MS = options.animation_duration_ms this.TOMBSTONE_CLASS = options.tombstone_class this.INVISIBLE_CLASS = options.invisible_class this.MAX_COUNT = MAX_COUNT this.column = options.column || 1 this.waterflow = options.waterflow this.anchorItem = { index: 0, offset: 0 } this.timer = null this.firstAttachedItem_ = 0 this.lastAttachedItem_ = 0 this.anchorScrollTop = 0 this.tombstoneSize_ = 0 this.tombstoneWidth_ = 0 this.tombstones_ = [] this.scroller_ = scroller this.source_ = source this.items_ = options.list || [] this.loadedItems_ = 0 this.requestInProgress_ = false this.cacheVM = options.cacheVM this.options = options if (!this.source_.fetch) { this.setItems(options.list) } this.curPos = 0 this.unusedNodes = [] this.baseNode = document.createElement('div') this.scroller_.addEventListener('scroll', this.onScroll_.bind(this)) window.addEventListener('resize', this.onResize_.bind(this)) window.addEventListener('orientationchange', this.onResize_.bind(this)) // Create an element to force the scroller to allow scrolling to a certain // point. // this.scrollRunway_ = document.createElement('div') // // Internet explorer seems to require some text in this div in order to // // ensure that it can be scrolled to. // this.scrollRunway_.textContent = ' ' // this.scrollRunwayEnd_ = 0 // this.scrollRunway_.style.position = 'absolute' // this.scrollRunway_.style.height = '1px' // this.scrollRunway_.style.width = '1px' // this.scrollRunway_.style.transition = 'transform 0.2s' // this.scroller_.appendChild(this.scrollRunway_) this.initPosList() this.onResize_() } InfiniteScroller.prototype = { /** * Called when the browser window resizes to adapt to new scroller bounds and * layout sizes of items within the scroller. */ onResize_ () { // TODO: If we already have tombstones attached to the document, it would // probably be more efficient to use one of them rather than create a new // one to measure. var tombstone = this.source_.createTombstone(this.baseNode.cloneNode(true)) tombstone.style.position = 'absolute' this.scroller_.appendChild(tombstone) tombstone.classList.remove(this.INVISIBLE_CLASS) this.tombstoneSize_ = tombstone.offsetHeight / this.column this.tombstoneWidth_ = tombstone.offsetWidth this.scroller_.removeChild(tombstone) // Reset the cached size of items in the scroller as they may no longer be // correct after the item content undergoes layout. for (var i = 0; i < this.items_.length; i++) { this.items_[i].top = -1 this.items_[i].height = this.items_[i].width = this.items_[i].cacheHeightCount = 0 } this.onScroll_() }, /** * Called when the scroller scrolls. This determines the newly anchored item * and offset and then updates the visible elements, requesting more items * from the source if we've scrolled past the end of the currently available * content. */ onScroll_ () { const delta = this.scroller_.scrollTop - this.anchorScrollTop if (this.scroller_.scrollTop == 0) { this.anchorItem = { index: 0, offset: 0 } } else { this.anchorItem = this.calculateAnchoredItem(this.anchorItem, delta) } this.anchorScrollTop = this.scroller_.scrollTop const lastScreenItem = this.calculateAnchoredItem(this.anchorItem, this.scroller_.offsetHeight) if (delta < 0) { this.fill(this.anchorItem.index - this.RUNWAY_ITEMS, lastScreenItem.index + this.RUNWAY_ITEMS_OPPOSITE) } else { this.fill(this.anchorItem.index - this.RUNWAY_ITEMS_OPPOSITE, lastScreenItem.index + this.RUNWAY_ITEMS) } }, /** * Calculates the item that should be anchored after scrolling by delta from * the initial anchored item. * @param {{index: number, offset: number}} initialAnchor The initial position * to scroll from before calculating the new anchor position. * @param {number} delta The offset from the initial item to scroll by. * @return {{index: number, offset: number}} Returns the new item and offset * scroll should be anchored to. */ calculateAnchoredItem (initialAnchor, delta) { if (delta === 0) return initialAnchor delta += initialAnchor.offset var i = initialAnchor.index var tombstones = 0 if (delta < 0) { while (delta < 0 && i > 0 && this.items_[i - 1].height) { delta += this.items_[i - 1].height i-- } tombstones = Math.max(-i, Math.ceil(Math.min(delta, 0) / this.tombstoneSize_)) } else { while (delta > 0 && i < this.items_.length && this.items_[i].height && this.items_[i].height < delta) { delta -= this.items_[i].height i++ } if (i >= this.items_.length || !this.items_[i].height) tombstones = Math.floor(Math.max(delta, 0) / this.tombstoneSize_) } i += tombstones delta -= tombstones * this.tombstoneSize_ i = Math.min(i, this.MAX_COUNT - 1) return { index: Math.floor(i / this.column) * this.column, offset: delta } }, /** * Sets the range of items which should be attached and attaches those items. * @param {number} start The first item which should be attached. * @param {number} end One past the last item which should be attached. */ fill (start, end) { this.firstAttachedItem_ = Math.max(0, start) this.lastAttachedItem_ = end this.attachContent() }, /** * Creates or returns an existing tombstone ready to be reused. * @return {Element} A tombstone element ready to be used. */ getTombstone () { const tombstone = this.tombstones_.pop() if (tombstone) { tombstone.classList.remove(this.INVISIBLE_CLASS) tombstone.style.opacity = 1 tombstone.style.transform = '' tombstone.style.transition = '' return tombstone } return this.source_.createTombstone(this.baseNode.cloneNode(true)) }, layoutInView (i) { const top = this.posList.get(Math.floor(i / this.column), i % this.column) if (!top) return true const index = top - this.anchorScrollTop return (index > -window.innerHeight * .5 && index < window.innerHeight) }, getUnUsedNodes (clearAll) { if (this.waterflow) { for (let i = 0, len = this.items_.length; i < len; i++) { if (this.items_[i].node && (clearAll || !this.layoutInView(i))) { if (this.items_[i].vm) { this.clearItem(this.items_[i]) } else { this.clearTombstone(this.items_[i]) } this.items_[i].vm = null this.items_[i].node = null } } } else { for (let i = 0, len = this.items_.length; i < len; i++) { if (i === this.firstAttachedItem_) { i = this.lastAttachedItem_ - 1 continue } if (this.items_[i].vm) { this.clearItem(this.items_[i]) } else { this.clearTombstone(this.items_[i]) } this.items_[i].vm = null this.items_[i].node = null } } }, clearItem (item) { if (this.options.reuseVM) { this.scroller_.removeChild(item.node) this.source_.free(item.data) } else { if (this.cacheVM && item.node) { return this.scroller_.removeChild(item.node) } item.vm.$destroy() if (item.node) { this.unusedNodes.push(item.node) } } }, clearTombstone (item) { if (item.node) { if (item.node.classList.contains(this.TOMBSTONE_CLASS)) { this.tombstones_.push(item.node) this.tombstones_[this.tombstones_.length - 1].classList.add(this.INVISIBLE_CLASS) } else { this.unusedNodes.push(item.node) } } }, clearUnUsedNodes () { while (this.unusedNodes.length) { this.scroller_.removeChild(this.unusedNodes.pop()) } }, getNodePosition () { // Fix scroll position in case we have realized the heights of elements // that we didn't used to know. // TODO: We should only need to do this when a height of an item becomes // known above. this.anchorScrollTop = 0 for (let i = 0; i < this.anchorItem.index; i++) { this.anchorScrollTop += this.items_[i].height || this.tombstoneSize_ } this.anchorScrollTop += this.anchorItem.offset this.curPos = this.anchorScrollTop - this.anchorItem.offset let i = this.anchorItem.index while (i > this.firstAttachedItem_) { this.curPos -= this.items_[i - 1].height || this.tombstoneSize_ i-- } while (i < this.firstAttachedItem_) { this.curPos += this.items_[i].height || this.tombstoneSize_ i++ } }, initPosList () { let data = {} for (let i = 0, len = this.column; i < len; i++) { data[i] = this.curPos } this.posList = { data: { 0: data }, get (row, col) { if (!this.data[row]) { let data = {} for (let i = 0, len = this.column; i < len; i++) { data[i] = this.curPos } this.data[row] = data // Array.from({ length: this.column }).map(i => this.curPos) } if (col === undefined) return this.data[row] return this.data[row][col] }, set (row, col, val) { this.get(row)[col] = val } } }, tombstoneLayout (tombstoneAnimations) { let i let anim let x for (i in tombstoneAnimations) { anim = tombstoneAnimations[i] x = (i % this.column) * this.items_[i].width this.items_[i].node.style.transform = 'translate3d(' + x + 'px,' + (this.anchorScrollTop + anim[1]) * this.column + 'px, 0) scale(' + (this.tombstoneWidth_ / this.items_[i].width) + ', ' + (this.tombstoneSize_ / this.items_[i].height) + ')' // Call offsetTop on the nodes to be animated to force them to apply current transforms. this.items_[i].node.offsetTop anim[0].offsetTop this.items_[i].node.style.transition = 'transform ' + this.ANIMATION_DURATION_MS + 'ms' } }, itemLayout (tombstoneAnimations) { let i let anim let x = 0 let y = 0 let row = 0 let curPosList let size = 0 for (i = this.firstAttachedItem_; i < this.lastAttachedItem_; i++) { anim = tombstoneAnimations[i] if (this.waterflow) { row = Math.floor(i / this.column) } x = (i % this.column) * (this.items_[i].width || this.tombstoneWidth_) y = this.waterflow ? this.posList.get(row, i % this.column) : this.curPos if (anim) { anim[0].style.transition = 'transform ' + this.ANIMATION_DURATION_MS + 'ms, opacity ' + this.ANIMATION_DURATION_MS + 'ms' anim[0].style.transform = 'translate3d(' + x + 'px,' + y + 'px, 0) scale(' + (this.items_[i].width / this.tombstoneWidth_) + ', ' + (this.items_[i].height / this.tombstoneSize_) + ')' anim[0].style.opacity = 0 } if (this.items_[i].node && this.curPos !== this.items_[i].top) { if (!anim) this.items_[i].node.style.transition = '' this.items_[i].node.style.transform = 'translate3d('+ x + 'px,' + y + 'px, 0)' } this.items_[i].top = y if ((i + 1) % this.column === 0) { this.curPos += (this.items_[i].height || this.tombstoneSize_) * this.column } if (this.waterflow) { this.posList.set(row + 1, i % this.column, y + (this.items_[i].height || this.tombstoneSize_) * this.column) } } }, setAnimatePosition (tombstoneAnimations) { this.tombstoneLayout(tombstoneAnimations) this.itemLayout(tombstoneAnimations) }, renderItems () { let tombstoneAnimations = {} let node let newNodes = [] let i const last = Math.floor((this.lastAttachedItem_ + this.RUNWAY_ITEMS) / this.column) * this.column if (last > this.MAX_COUNT) { this.lastAttachedItem_ = this.MAX_COUNT } // Create DOM nodes. for (i = this.firstAttachedItem_; i < this.lastAttachedItem_; i++) { while (this.items_.length <= i) { this.addItem_() } if (this.items_[i].node) { // if it's a tombstone but we have data, replace it. if (this.items_[i].node.classList.contains(this.TOMBSTONE_CLASS) && this.items_[i].data) { // TODO: Probably best to move items on top of tombstones and fade them in instead. if (this.ANIMATION_DURATION_MS) { this.items_[i].node.style.zIndex = 1; tombstoneAnimations[i] = [this.items_[i].node, this.items_[i].top - this.anchorScrollTop] } else { this.items_[i].node.classList.add(this.INVISIBLE_CLASS) this.tombstones_.push(this.items_[i].node) } this.items_[i].node = null } else { continue } } if (this.waterflow) { if (this.layoutInView(i)) { if (this.items_[i].data) { node = this.source_.render(this.items_[i].data, (this.unusedNodes.pop() || this.baseNode.cloneNode(true)), this.items_[i]) } else { node = this.getTombstone() } // Maybe don't do this if it's already attached? node.style.position = 'absolute' this.items_[i].top = -1 // this.scroller_.appendChild(node) this.items_[i].node = node newNodes.push(node) } } else { if (this.items_[i].data) { node = this.source_.render(this.items_[i].data, (this.unusedNodes.pop() || this.baseNode.cloneNode(true)), this.items_[i]) } else { node = this.getTombstone() } // Maybe don't do this if it's already attached? node.style.position = 'absolute' this.items_[i].top = -1 // this.scroller_.appendChild(node) this.items_[i].node = node newNodes.push(node) } } let len = newNodes.length for (i = 0; i < len; i++) { this.scroller_.appendChild(newNodes[i]) } return tombstoneAnimations }, cacheItemHeight (force) { let rect = {} for (let i = this.firstAttachedItem_; i < this.lastAttachedItem_; i++) { // cacheItemsHeight if (this.items_[i].data && this.items_[i].node && (force || !this.items_[i].height)) { this.items_[i].height = this.items_[i].node.offsetHeight / this.column this.items_[i].width = this.items_[i].node.offsetWidth this.items_[i].cacheHeightCount = 0 } else if (this.items_[i].cacheHeightCount < 10) { // if height's cache is not match this.items_[i].cacheHeightCount++ if (this.items_[i].height && this.items_[i].node && this.items_[i].height !== this.items_[i].node.offsetHeight / this.column) { this.items_[i].height = this.items_[i].node.offsetHeight / this.column } } } }, /** * Attaches content to the scroller and updates the scroll position if * necessary. */ attachContent () { this.getUnUsedNodes() let tombstoneAnimations = this.renderItems() this.clearUnUsedNodes() this.cacheItemHeight() this.getNodePosition() this.setAnimatePosition(tombstoneAnimations) // this.setScrollRunway() if (this.ANIMATION_DURATION_MS) { // TODO: Should probably use transition end, but there are a lot of animations we could be listening to. setTimeout(() => { this.tombstoneAnimation(tombstoneAnimations) }, this.ANIMATION_DURATION_MS) } this.maybeRequestContent() }, setItems (list) { list = list || [] this.items_ = list this.MAX_COUNT = list.length }, scrollToIndex (index) { const commonItemCount = this.lastAttachedItem_ - this.firstAttachedItem_ this.fill(index - commonItemCount, index + 1) }, setScrollRunway () { this.scrollRunwayEnd_ = Math.max(this.scrollRunwayEnd_, this.curPos + this.SCROLL_RUNWAY) this.scrollRunway_.style.transform = 'translate(0, ' + this.scrollRunwayEnd_ + 'px)' this.scroller_.scrollTop = this.anchorScrollTop }, tombstoneAnimation (tombstoneAnimations) { let anim for (var i in tombstoneAnimations) { anim = tombstoneAnimations[i] anim[0].classList.add(this.INVISIBLE_CLASS) this.tombstones_.push(anim[0]) } tombstoneAnimations = null }, /** * Requests additional content if we don't have enough currently. */ maybeRequestContent () { // Don't issue another request if one is already in progress as we don't // know where to start the next request yet. if (this.requestInProgress_) return var itemsNeeded = this.lastAttachedItem_ - this.loadedItems_; if (itemsNeeded <= 0) return this.requestInProgress_ = true if (!this.source_.fetch) return this.source_.fetch(itemsNeeded, this.loadedItems_).then(data => { this.MAX_COUNT = data.count this.addContent(data.list) }) }, /** * Adds an item to the items list. */ addItem_ () { this.items_.push({ vm: null, data: null, node: null, height: 0, width: 0, top: 0, }) }, /** * Adds the given array of items to the items list and then calls * attachContent to update the displayed content. * @param {Array<Object>} items The array of items to be added to the infinite * scroller list. */ addContent (items) { if (!items.length) return this.requestInProgress_ = false let index for (var i = 0; i < items.length; i++) { if (this.items_.length <= this.loadedItems_) { this.addItem_() } if (this.loadedItems_ <= this.MAX_COUNT) { index = this.loadedItems_++ this.items_[index].data = items[i] } } this.attachContent() }, clear () { this.loadedItems_ = 0 this.requestInProgress_ = false this.firstAttachedItem_ = -1 this.lastAttachedItem_ = -1 this.getUnUsedNodes(true) this.clearUnUsedNodes() this.items_ = [] this.onResize_() }, destroy () { this.scroller_.removeEventListener('scroll', this.onScroll_) window.removeEventListener('resize', this.onResize_) window.removeEventListener('orientationchange', this.onResize_) this.clear() } }