UNPKG

vue3-infinite-list

Version:

An infinite scrolling list of vue3, of course vue2 can also be used.

540 lines (539 loc) 17.1 kB
var __defProp = Object.defineProperty; var __defProps = Object.defineProperties; var __getOwnPropDescs = Object.getOwnPropertyDescriptors; var __getOwnPropSymbols = Object.getOwnPropertySymbols; var __hasOwnProp = Object.prototype.hasOwnProperty; var __propIsEnum = Object.prototype.propertyIsEnumerable; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __spreadValues = (a, b) => { for (var prop in b || (b = {})) if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]); if (__getOwnPropSymbols) for (var prop of __getOwnPropSymbols(b)) { if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]); } return a; }; var __spreadProps = (a, b) => __defProps(a, __getOwnPropDescs(b)); var __publicField = (obj, key, value) => { __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); return value; }; import { defineComponent, ref, toRefs, reactive, onMounted, onBeforeUnmount, watch } from "vue-demi"; import { openBlock, createElementBlock, normalizeStyle, createElementVNode, Fragment, renderList, renderSlot } from "vue"; const ALIGN_AUTO = "auto"; const ALIGN_START = "start"; const ALIGN_CENTER = "center"; const ALIGN_END = "end"; const DIRECTION_VERTICAL = "vertical"; const DIRECTION_HORIZONTAL = "horizontal"; const SCROLL_CHANGE_OBSERVED = "observed"; const SCROLL_CHANGE_REQUESTED = "requested"; const scrollProp = { [DIRECTION_VERTICAL]: "scrollTop", [DIRECTION_HORIZONTAL]: "scrollLeft" }; const sizeProp = { [DIRECTION_VERTICAL]: "height", [DIRECTION_HORIZONTAL]: "width" }; const positionProp = { [DIRECTION_VERTICAL]: "top", [DIRECTION_HORIZONTAL]: "left" }; const STYLE_WRAPPER = { overflow: "auto", willChange: "transform", WebkitOverflowScrolling: "touch" }; const STYLE_INNER = { position: "relative", overflow: "hidden", width: "100%", minHeight: "100%" }; const STYLE_ITEM = { position: "absolute", left: 0, width: "100%", height: "100%" }; class SizeAndPosManager { constructor({ itemCount, itemSizeGetter, estimatedItemSize }) { __publicField(this, "itemSizeGetter"); __publicField(this, "itemCount"); __publicField(this, "estimatedItemSize"); __publicField(this, "lastMeasuredIndex"); __publicField(this, "itemSizeAndPositionData"); this.itemSizeGetter = itemSizeGetter; this.itemCount = itemCount; this.estimatedItemSize = estimatedItemSize; this.itemSizeAndPositionData = {}; this.lastMeasuredIndex = -1; } updateConfig({ itemCount, estimatedItemSize }) { this.itemCount = itemCount; this.estimatedItemSize = estimatedItemSize; } getLastMeasuredIndex() { return this.lastMeasuredIndex; } destroy() { for (let key in this.itemSizeAndPositionData) { delete this.itemSizeAndPositionData[key]; } } getSizeAndPositionForIndex(index) { if (index < 0 || index >= this.itemCount) { throw Error(`Requested index ${index} is outside of range 0..${this.itemCount}`); } if (index > this.lastMeasuredIndex) { const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); let offset = lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size; for (let i = this.lastMeasuredIndex + 1; i <= index; i++) { const size = this.itemSizeGetter(i); if (size == null || isNaN(size)) { throw Error(`Invalid size returned for index ${i} of value ${size}`); } this.itemSizeAndPositionData[i] = { offset, size }; offset += size; } this.lastMeasuredIndex = index; } return this.itemSizeAndPositionData[index]; } getSizeAndPositionOfLastMeasuredItem() { return this.lastMeasuredIndex >= 0 ? this.itemSizeAndPositionData[this.lastMeasuredIndex] : { offset: 0, size: 0 }; } getTotalSize() { const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); return lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size + (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize; } getUpdatedOffsetForIndex({ align = ALIGN_START, containerSize, currentOffset, targetIndex }) { if (containerSize <= 0) { return 0; } const datum = this.getSizeAndPositionForIndex(targetIndex); const maxOffset = datum.offset; const minOffset = maxOffset - containerSize + datum.size; let idealOffset; switch (align) { case ALIGN_END: idealOffset = minOffset; break; case ALIGN_CENTER: idealOffset = maxOffset - (containerSize - datum.size) / 2; break; case ALIGN_START: idealOffset = maxOffset; break; default: idealOffset = Math.max(minOffset, Math.min(maxOffset, currentOffset)); } const totalSize = this.getTotalSize(); return Math.max(0, Math.min(totalSize - containerSize, idealOffset)); } getVisibleRange({ containerSize, offset, overscanCount }) { const totalSize = this.getTotalSize(); if (totalSize === 0) { return {}; } const maxOffset = offset + containerSize; let start = this.findNearestItem(offset); if (typeof start === "undefined") { throw Error(`Invalid offset ${offset} specified`); } const datum = this.getSizeAndPositionForIndex(start); offset = datum.offset + datum.size; let stop = start; while (offset < maxOffset && stop < this.itemCount - 1) { stop++; offset += this.getSizeAndPositionForIndex(stop).size; } if (overscanCount) { start = Math.max(0, start - overscanCount); stop = Math.min(stop + overscanCount, this.itemCount - 1); } return { start, stop }; } resetItem(index) { this.lastMeasuredIndex = Math.min(this.lastMeasuredIndex, index - 1); } findNearestItem(offset) { if (isNaN(offset)) { throw Error(`Invalid offset ${offset} specified`); } offset = Math.max(0, offset); const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem(); const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex); if (lastMeasuredSizeAndPosition.offset >= offset) { return this.binarySearch({ high: lastMeasuredIndex, low: 0, offset }); } else { return this.exponentialSearch({ index: lastMeasuredIndex, offset }); } } binarySearch({ low, high, offset }) { let middle = 0; let currentOffset = 0; while (low <= high) { middle = low + Math.floor((high - low) / 2); currentOffset = this.getSizeAndPositionForIndex(middle).offset; if (currentOffset === offset) { return middle; } else if (currentOffset < offset) { low = middle + 1; } else if (currentOffset > offset) { high = middle - 1; } } if (low > 0) { return low - 1; } return 0; } exponentialSearch({ index, offset }) { let interval = 1; while (index < this.itemCount && this.getSizeAndPositionForIndex(index).offset < offset) { index += interval; interval *= 2; } return this.binarySearch({ high: Math.min(index, this.itemCount - 1), low: Math.floor(index / 2), offset }); } } class Util { addEventListener(ele, eventType, callback) { if (ele.addEventListener) { return ele.addEventListener(eventType, callback, false); } else if (ele["attachEvent"]) { return ele["attachEvent"](eventType, callback); } else { return ele["on" + eventType] = callback; } } removeEventListener(ele, eventType, callback) { if (ele.removeEventListener) { return ele.removeEventListener(eventType, callback, false); } else if (ele["detachEvent"]) { return ele["detachEvent"](eventType, callback); } else { return ele["on" + eventType] = null; } } isArray(val) { return Object.prototype.toString.call(val) === "[object Array]"; } randomColor() { return "#" + ("00000" + (Math.random() * 16777216 << 0).toString(16)).slice(-6); } isPureNumber(val) { if (typeof val === "number" || !val) return true; else return false; } } class ILEvent { constructor() { __publicField(this, "items", []); __publicField(this, "offset", 0); __publicField(this, "data", null); __publicField(this, "start", 0); __publicField(this, "stop", 0); __publicField(this, "total", 0); } toString() { return `start:${this.start} stop:${this.stop} total:${this.total} offset:${this.offset}`; } } var _export_sfc = (sfc, props) => { const target = sfc.__vccOpts || sfc; for (const [key, val] of props) { target[key] = val; } return target; }; const _sfc_main = defineComponent({ name: "InfiniteList", props: { scrollDirection: { type: String, default: DIRECTION_VERTICAL }, scrollToAlignment: { type: String, default: ALIGN_AUTO }, overscanCount: { type: Number, default: 4 }, itemSize: { type: null, required: true }, data: { type: [Array, null], default: [], required: true }, unit: { type: String, default: "px" }, width: { type: [Number, String] }, height: { type: [Number, String] }, debug: { type: Boolean, default: false }, scrollOffset: Number, scrollToIndex: Number, estimatedItemSize: Number }, setup(props, { attrs, slots, emit }) { let rootNode = ref(null); let innerNode = ref(null); let warpStyle = ref(null); let innerStyle = ref(null); let items = []; let offset; let oldOffset; let scrollChangeReason; let sizeAndPosManager; let styleCache = {}; const { itemSize, scrollDirection, scrollToIndex } = toRefs(props); const util = new Util(); const event = reactive(new ILEvent()); const getItemStyle = (index) => { index += event.start; const style = styleCache[index]; if (style) return style; const { size, offset: offset2 } = sizeAndPosManager.getSizeAndPositionForIndex(index); const debugStyle = props.debug ? { backgroundColor: util.randomColor() } : null; return styleCache[index] = __spreadProps(__spreadValues(__spreadValues({}, STYLE_ITEM), debugStyle), { [getCurrSizeProp()]: addUnit(size), [positionProp[props.scrollDirection]]: addUnit(offset2) }); }; const initAll = () => { createSizeAndPosManager(); util.addEventListener(rootNode.value, "scroll", handleScroll); offset = props.scrollOffset || props.scrollToIndex != null && getOffsetForIndex(props.scrollToIndex) || 0; scrollChangeReason = SCROLL_CHANGE_REQUESTED; setTimeout(() => { if (props.scrollOffset != null) { scrollTo(props.scrollOffset); } else if (props.scrollToIndex != null) { scrollTo(getOffsetForIndex(props.scrollToIndex)); } }, 0); setDomStyle(); scrollRender(); }; const handleScroll = (e) => { const nodeOffset = getNodeOffset(); if (nodeOffset < 0 || offset === nodeOffset || e.target !== rootNode.value) return; offset = nodeOffset; scrollChangeReason = SCROLL_CHANGE_OBSERVED; scrollRender(); }; const scrollRender = () => { const { start, stop } = sizeAndPosManager.getVisibleRange({ containerSize: getCurrSizeVal() || 0, offset: offset || 0, overscanCount: props.overscanCount }); if (typeof start !== "undefined" && typeof stop !== "undefined") { items.length = 0; for (let i = start; i <= stop; i++) { items.push(props.data[i]); } event.start = start; event.stop = stop; event.offset = offset; event.items = items; event.total = getItemCount(); if (!util.isPureNumber(itemSize.value)) { innerStyle.value = __spreadProps(__spreadValues({}, STYLE_INNER), { [getCurrSizeProp()]: addUnit(sizeAndPosManager.getTotalSize()) }); } if (props.debug) { console.log(event.toString()); } } renderEnd(); }; const scrollTo = (value) => { rootNode.value[getCurrScrollProp()] = value; oldOffset = value; }; const renderEnd = () => { if (oldOffset !== offset && scrollChangeReason === SCROLL_CHANGE_REQUESTED) { scrollTo(offset); } }; const createSizeAndPosManager = () => { if (!sizeAndPosManager) sizeAndPosManager = new SizeAndPosManager({ itemCount: getItemCount(), itemSizeGetter: (index) => getSize(index), estimatedItemSize: getEstimatedItemSize() }); return sizeAndPosManager; }; const getNodeOffset = () => { return rootNode.value[getCurrScrollProp()]; }; const getCurrSizeProp = () => { return sizeProp[scrollDirection.value]; }; const getCurrSizeVal = () => { return props[getCurrSizeProp()]; }; const getCurrScrollProp = () => { return scrollProp[scrollDirection.value]; }; const getOffsetForIndex = (index, scrollToAlignment = props.scrollToAlignment, itemCount = getItemCount()) => { if (index < 0 || index >= itemCount) index = 0; return sizeAndPosManager.getUpdatedOffsetForIndex({ align: props.scrollToAlignment, containerSize: getCurrSizeVal(), currentOffset: offset || 0, targetIndex: index }); }; const getSize = (index) => { if (typeof itemSize.value === "function") { return itemSize.value(index); } return util.isArray(itemSize.value) ? itemSize.value[index] : itemSize.value; }; const getItemCount = () => { return props.data ? props.data.length : 0; }; const getEstimatedItemSize = () => { return props.estimatedItemSize || typeof itemSize.value === "number" && itemSize.value || 50; }; const recomputeSizes = (startIndex = 0) => { styleCache = {}; sizeAndPosManager.resetItem(startIndex); }; const addUnit = (val) => { return typeof val === "string" ? val : val + props.unit; }; const setDomStyle = () => { warpStyle.value = __spreadProps(__spreadValues({}, STYLE_WRAPPER), { height: addUnit(props.height), width: addUnit(props.width) }); innerStyle.value = __spreadProps(__spreadValues({}, STYLE_INNER), { [getCurrSizeProp()]: addUnit(sizeAndPosManager.getTotalSize()) }); }; const clearStyleCache = () => { for (let key in styleCache) { delete styleCache[key]; } }; onMounted(() => setTimeout(initAll)); onBeforeUnmount(() => { clearStyleCache(); sizeAndPosManager.destroy(); util.removeEventListener(rootNode.value, "scroll", handleScroll); }); watch(() => props.debug, (newVal, oldVal) => clearStyleCache()); watch(() => props.data, (newVal, oldVal) => { sizeAndPosManager.updateConfig({ itemCount: getItemCount(), estimatedItemSize: getEstimatedItemSize() }); oldOffset = null; recomputeSizes(); setDomStyle(); setTimeout(scrollRender, 0); }); watch(() => props.scrollOffset, (newVal, oldVal) => { offset = props.scrollOffset || 0; scrollChangeReason = SCROLL_CHANGE_REQUESTED; scrollRender(); }); watch(() => props.scrollToIndex, (newVal, oldVal) => { offset = getOffsetForIndex(props.scrollToIndex, props.scrollToAlignment, getItemCount()); scrollChangeReason = SCROLL_CHANGE_REQUESTED; scrollRender(); }); return { rootNode, innerNode, warpStyle, innerStyle, getItemStyle, event }; } }); function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) { var _a; return openBlock(), createElementBlock("div", { ref: "rootNode", style: normalizeStyle(_ctx.warpStyle) }, [ createElementVNode("div", { ref: "innerNode", style: normalizeStyle(_ctx.innerStyle) }, [ (openBlock(true), createElementBlock(Fragment, null, renderList((_a = _ctx.event) == null ? void 0 : _a.items, (item, i) => { var _a2; return openBlock(), createElementBlock("div", { style: normalizeStyle(_ctx.getItemStyle(i)), key: ((_a2 = _ctx.event) == null ? void 0 : _a2.start) + i, class: "vue3-infinite-list" }, [ renderSlot(_ctx.$slots, "default", { event: _ctx.event, item, index: _ctx.event.start + i }) ], 4); }), 128)) ], 4) ], 4); } var InfiniteList = /* @__PURE__ */ _export_sfc(_sfc_main, [["render", _sfc_render]]); export { InfiniteList as default };