vue3-infinite-list
Version:
An infinite scrolling list of vue3, of course vue2 can also be used.
540 lines (539 loc) • 17.1 kB
JavaScript
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 };