提交 74bdbe32 编写于 作者: S suzigang

feat(list): 优化虚拟列表,支持不固定高度 #1658 #1382

上级 55ad6083
......@@ -2,10 +2,10 @@
<div class="demo">
<h2>{{ translate('basic') }}</h2>
<nut-cell>
<nut-list :height="50" :listData="count" @scroll-bottom="handleScroll">
<template v-slot="{ item }">
<nut-list :listData="count" @scroll-bottom="handleScroll">
<template v-slot="{ item, index }">
<div class="list-item">
{{ item }}
{{ index }}
</div>
</template>
</nut-list>
......@@ -48,18 +48,18 @@ export default createDemo({
}
});
</script>
<style lang="scss" scoped>
<style lang="scss">
.demo {
.nut-cell {
height: 100%;
}
.list-item {
.nut-list-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 50px;
margin-bottom: 10px;
height: 50px;
background-color: #f4a8b6;
border-radius: 10px;
}
......
......@@ -27,10 +27,10 @@ app.use();
<div class="demo">
<h2>Basic Usage</h2>
<nut-cell>
<nut-list :height="50" :listData="count" @scroll-bottom="handleScroll">
<template v-slot="{ item }">
<nut-list :listData="count" @scroll-bottom="handleScroll">
<template v-slot="{ item. index }">
<div class="list-item">
{{ item }}
{{ index }}
</div>
</template>
</nut-list>
......@@ -70,17 +70,16 @@ body {
height: 100%;
}
.demo {
height: 100%;
.nut-cell {
height: 100%;
}
.list-item {
.nut-list-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 50px;
margin-bottom: 10px;
height: 50px;
background-color: #f4a8b6;
border-radius: 10px;
}
......@@ -96,9 +95,11 @@ body {
| Attribute | Description | Type | Default |
|--------------|----------------------------------|--------|------------------|
| height | The height of the list item | Number | `50` |
| height | The height/estimated height of the list item, supports unfixed height | Number | `80` |
| list-data | List data | any[] | `[]` |
| container-height `v3.1.19` | Container height(The maximum value cannot exceed the viewable area) | Number | `Visual area height` |
| buffer-size `v3.3.5` | data buffer length | Number | `5` |
| margin `v3.3.5` | The gap between the lists is consistent with the custom style | Number | `10` |
### Slots
......@@ -111,5 +112,6 @@ body {
| Event | Description | Arguments |
|--------|----------------|--------------|
| scroll(Will be abandoned), `scroll-bottom` replaced | Triggered when scrolling to the bottom | - |
| scroll-bottom `v3.1.21` | Triggered when scrolling to the bottom | - |
\ No newline at end of file
| scroll-bottom `v3.1.21` | Triggered when scrolling to the bottom | - |
| scroll-up `v3.3.5` | scroll up | - |
| scroll-down `v3.3.5` | scroll down | - |
\ No newline at end of file
......@@ -28,9 +28,9 @@ app.use();
<h2>基础用法</h2>
<nut-cell>
<nut-list :height="50" :listData="count" @scroll-bottom="handleScroll">
<template v-slot="{ item }">
<template v-slot="{ item, index }">
<div class="list-item">
{{ item }}
{{ index }}
</div>
</template>
</nut-list>
......@@ -70,11 +70,10 @@ body {
height: 100%;
}
.demo {
height: 100%;
.nut-cell {
height: 100%;
}
.list-item {
.nut-list-item {
display: flex;
align-items: center;
justify-content: center;
......@@ -96,20 +95,23 @@ body {
| 参数 | 说明 | 类型 | 默认值 |
|--------------|----------------------------------|--------|------------------|
| height | 列表项的高度 | Number | `50` |
| height | 列表项的高度/预估高度,支持不固定高度 | Number | `80` |
| list-data | 列表数据 | any[] | `[]` |
| container-height `v3.1.19` | 容器高度(最大值不能超过可视区) | Number | `可视区高度` |
| buffer-size `v3.3.5` | 数据缓冲区长度 | Number | `5` |
| margin `v3.3.5` | 列表之间的间隙,和自定义样式保持一致 | Number | `10` |
### Slots
| 参数 | 说明 | 类型 |
|--------------|----------------------------------|--------|
| item | 列表项数据 | Object |
| index | 索引 | Number |
| index | 列表索引 | Number |
### Events
| 事件名 | 说明 | 回调参数 |
|--------|----------------|--------------|
| scroll(将被废弃), `scroll-bottom` 代替 | 滚动到底部时触发 | - |
| scroll-bottom `v3.1.21` | 滚动到底部时触发 | - |
\ No newline at end of file
| scroll-bottom `v3.1.21` | 滚动到底部时触发 | - |
| scroll-up `v3.3.5` | 向上滚动 | - |
| scroll-down `v3.3.5` | 向下滚动 | - |
\ No newline at end of file
.nut-list {
width: 100%;
overflow: scroll;
position: relative;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
&-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
position: relative;
z-index: -1;
}
&-container {
position: absolute;
top: 0;
left: 0;
right: 0;
top: 0;
position: absolute;
}
&-item {
overflow: hidden;
margin: $list-item-margin;
}
}
......@@ -6,22 +6,41 @@
scroll-top="0"
@scroll="handleScrollEvent"
ref="list"
:id="'list' + refRandomId"
>
<div class="nut-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="nut-list-container" :style="{ transform: getTransform }">
<div class="nut-list-item" :style="{ height: height + 'px' }" v-for="(item, index) in visibleData" :key="item">
<slot :item="item" :index="index"></slot>
<div
class="nut-list-phantom"
:style="{ height: phantomHeight + 'px' }"
ref="phantom"
:id="'phantom' + refRandomId"
></div>
<div
class="nut-list-container"
:style="{ transform: getTransform() }"
ref="actualContent"
:id="'actualContent' + refRandomId"
>
<div
class="nut-list-item"
v-for="(item, index) in visibleData"
:key="item"
:id="'list-item-' + Number(index + start)"
>
<slot :item="item" :index="index + start"></slot>
</div>
</div>
</Nut-Scroll-View>
</template>
<script lang="ts">
import { reactive, toRefs, computed, ref, Ref, watch } from 'vue';
import { reactive, toRefs, computed, ref, Ref, watch, ComputedRef } from 'vue';
import { createComponent } from '@/packages/utils/create';
import NutScrollView from '../scrollView/index.taro.vue';
import Taro from '@tarojs/taro';
import { useTaroRect } from '@/packages/utils/useTaroRect';
import { CachedPosition, CompareResult, binarySearch } from './type';
const { componentName, create } = createComponent('list');
const clientHeight = Taro.getSystemInfoSync().windowHeight || 667;
export default create({
components: {
NutScrollView
......@@ -37,19 +56,37 @@ export default create({
return [];
}
},
bufferSize: {
type: Number,
default: 5
},
containerHeight: {
type: [Number],
default: clientHeight
},
estimateRowHeight: {
type: Number,
default: 80
},
margin: {
type: Number,
default: 10
}
},
emits: ['scroll', 'scroll-bottom'],
emits: ['scroll-up', 'scroll-down', 'scroll-bottom'],
setup(props, { emit }) {
const list = ref(null) as Ref;
const phantom = ref(null) as Ref;
const actualContent = ref(null) as Ref;
const refRandomId = Math.random().toString(36).slice(-8);
const state = reactive({
startOffset: 0,
start: 0,
list: props.listData.slice()
originStartIndex: 0,
scrollTop: 0,
list: props.listData.slice(),
cachePositions: [] as CachedPosition[],
phantomHeight: props.estimateRowHeight * props.listData.length
});
const getContainerHeight = computed(() => {
......@@ -57,15 +94,11 @@ export default create({
});
const visibleCount = computed(() => {
return Math.ceil(getContainerHeight.value / props.height);
return Math.ceil(getContainerHeight.value / props.estimateRowHeight);
});
const end = computed(() => {
return state.start + visibleCount.value;
});
const getTransform = computed(() => {
return `translate3d(0, ${state.startOffset}px, 0)`;
return Math.min(state.originStartIndex + visibleCount.value + props.bufferSize, state.list.length - 1);
});
const classes = computed(() => {
......@@ -75,38 +108,166 @@ export default create({
};
});
const listHeight = computed(() => {
return state.list.length * props.height;
const visibleData: ComputedRef = computed(() => {
return state.list.slice(state.start, end.value);
});
const visibleData = computed(() => {
return state.list.slice(state.start, Math.min(end.value, state.list.length));
});
const getTransform = () => {
if (actualContent.value) {
return `translate3d(0, ${state.start >= 1 ? state.cachePositions[state.start - 1].bottom : 0}px, 0)`;
}
};
const initCachedPosition = () => {
state.cachePositions = [];
for (let i = 0; i < state.list.length; ++i) {
state.cachePositions[i] = {
index: i,
height: props.estimateRowHeight,
top: i * props.estimateRowHeight,
bottom: (i + 1) * (props.estimateRowHeight + props.margin),
dValue: 0
};
}
};
const updateCachedPosition = () => {
let nodes: any[] = actualContent.value.childNodes;
nodes = Array.from(nodes).filter((node: HTMLDivElement) => node.nodeType === 1);
const start = nodes[0];
nodes.forEach(async (node: HTMLDivElement, index: number) => {
if (!node) return;
const rect = await useTaroRect(node, Taro);
if (rect && rect.height) {
const { height } = rect;
const oldHeight = state.cachePositions[index + state.start]
? state.cachePositions[index + state.start].height
: props.height;
const dValue = oldHeight - height;
if (dValue && state.cachePositions[index + state.start]) {
state.cachePositions[index + state.start].bottom -= dValue;
state.cachePositions[index + state.start].height = height;
state.cachePositions[index + state.start].dValue = dValue;
}
}
});
let startIndex = 0;
if (start) {
startIndex = state.start;
}
const cachedPositionsLen = state.cachePositions.length;
let cumulativeDiffHeight = state.cachePositions[startIndex].dValue;
state.cachePositions[startIndex].dValue = 0;
for (let i = startIndex + 1; i < cachedPositionsLen; ++i) {
const item = state.cachePositions[i];
state.cachePositions[i].top = state.cachePositions[i - 1].bottom;
state.cachePositions[i].bottom = state.cachePositions[i].bottom - cumulativeDiffHeight;
if (item.dValue !== 0) {
cumulativeDiffHeight += item.dValue;
item.dValue = 0;
}
}
const height = state.cachePositions[cachedPositionsLen - 1].bottom;
state.phantomHeight = height;
};
const getStartIndex = (scrollTop = 0) => {
let idx = binarySearch<CachedPosition, number>(
state.cachePositions,
scrollTop,
(currentValue: CachedPosition, targetValue: number) => {
const currentCompareValue = currentValue.bottom;
if (currentCompareValue === targetValue) {
return CompareResult.eq;
}
if (currentCompareValue < targetValue) {
return CompareResult.lt;
}
return CompareResult.gt;
}
) as number;
const targetItem = state.cachePositions[idx];
if (targetItem.bottom < scrollTop) {
idx += 1;
}
return idx;
};
const resetAllVirtualParam = () => {
state.originStartIndex = 0;
state.start = 0;
state.scrollTop = 0;
list.value.scrollTop = 0;
initCachedPosition();
state.phantomHeight = props.estimateRowHeight * state.list.length;
};
const handleScrollEvent = async (e: any) => {
const scrollTop = Math.max(e.detail ? e.detail.scrollTop : e.target.scrollTop, 0.1);
state.start = Math.floor(scrollTop / props.height);
if (end.value > state.list.length) {
emit('scroll');
emit('scroll-bottom');
const { originStartIndex } = state;
const currentIndex = getStartIndex(scrollTop);
if (currentIndex !== originStartIndex) {
state.originStartIndex = currentIndex;
state.start = Math.max(state.originStartIndex - props.bufferSize, 0);
if (end.value >= state.list.length - 1) {
emit('scroll-bottom');
}
}
state.startOffset = scrollTop - (scrollTop % props.height);
emit(scrollTop > state.scrollTop ? 'scroll-up' : 'scroll-down', scrollTop);
state.scrollTop = scrollTop;
};
watch(
() => props.listData,
(val: any[]) => {
state.list = val.slice();
if (state.list.length === val.length) {
setTimeout(() => {
initCachedPosition();
updateCachedPosition();
}, 200);
} else {
resetAllVirtualParam();
return;
}
}
);
watch(
() => state.start,
() => {
state.list = props.listData.slice();
if (actualContent.value && state.list.length > 0) {
Taro.nextTick(() => {
setTimeout(() => {
updateCachedPosition();
}, 200);
});
}
}
);
return {
...toRefs(state),
list,
phantom,
actualContent,
getTransform,
listHeight,
visibleData,
classes,
refRandomId,
getContainerHeight,
handleScrollEvent
};
......
<template>
<div :class="classes" :style="{ height: `${getContainerHeight}px` }" @scroll.passive="handleScrollEvent" ref="list">
<div class="nut-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="nut-list-container" :style="{ transform: getTransform }">
<div class="nut-list-item" :style="{ height: height + 'px' }" v-for="(item, index) in visibleData" :key="item">
<slot :item="item" :index="index"></slot>
<div class="nut-list-phantom" :style="{ height: phantomHeight + 'px' }" ref="phantom"></div>
<div class="nut-list-container" :style="{ transform: getTransform() }" ref="actualContent">
<div class="nut-list-item" v-for="(item, index) in visibleData" :key="item">
<slot :item="item" :index="index + start"></slot>
</div>
</div>
</div>
</template>
<script lang="ts">
import { reactive, toRefs, computed, ref, Ref, watch } from 'vue';
import { reactive, toRefs, computed, ref, Ref, watch, ComputedRef } from 'vue';
import { createComponent } from '@/packages/utils/create';
import { CachedPosition, CompareResult, binarySearch } from './type';
import { useRect } from '@/packages/utils/useRect';
const { componentName, create } = createComponent('list');
const clientHeight = document.documentElement.clientHeight || document.body.clientHeight || 667;
export default create({
props: {
height: {
type: [Number],
default: 50
},
listData: {
type: Array,
default: () => {
return [];
}
},
bufferSize: {
type: Number,
default: 5
},
containerHeight: {
type: [Number],
default: clientHeight
},
height: {
type: Number,
default: 80
},
margin: {
type: Number,
default: 10
}
},
emits: ['scroll', 'scroll-bottom'],
emits: ['scroll-up', 'scroll-down', 'scroll-bottom'],
setup(props, { emit }) {
const list = ref(null) as Ref;
const phantom = ref(null) as Ref;
const actualContent = ref(null) as Ref;
const state = reactive({
startOffset: 0,
start: 0,
list: props.listData.slice()
originStartIndex: 0,
scrollTop: 0,
list: props.listData.slice(),
cachePositions: [] as CachedPosition[],
phantomHeight: props.height * props.listData.length
});
const getContainerHeight = computed(() => {
......@@ -49,11 +65,7 @@ export default create({
});
const end = computed(() => {
return state.start + visibleCount.value;
});
const getTransform = computed(() => {
return `translate3d(0, ${state.startOffset}px, 0)`;
return Math.min(state.originStartIndex + visibleCount.value + props.bufferSize, state.list.length - 1);
});
const classes = computed(() => {
......@@ -63,36 +75,153 @@ export default create({
};
});
const listHeight = computed(() => {
return state.list.length * props.height;
const visibleData: ComputedRef = computed(() => {
return state.list.slice(state.start, end.value);
});
const visibleData = computed(() => {
return state.list.slice(state.start, Math.min(end.value, state.list.length));
});
const getTransform = () => {
if (actualContent.value) {
return `translate3d(0, ${state.start >= 1 ? state.cachePositions[state.start - 1].bottom : 0}px, 0)`;
}
};
const initCachedPosition = () => {
state.cachePositions = [];
for (let i = 0; i < state.list.length; ++i) {
state.cachePositions[i] = {
index: i,
height: props.height,
top: i * props.height,
bottom: (i + 1) * (props.height + props.margin),
dValue: 0
};
}
};
const updateCachedPosition = () => {
let nodes: any[] = actualContent.value.childNodes;
nodes = Array.from(nodes).filter((node: HTMLDivElement) => node.nodeType === 1);
const start = nodes[0];
nodes.forEach((node: HTMLDivElement, index: number) => {
if (!node) return;
const rect = useRect(node);
const { height } = rect;
const oldHeight = state.cachePositions[index + state.start].height;
const dValue = oldHeight - height;
if (dValue) {
state.cachePositions[index + state.start].bottom -= dValue;
state.cachePositions[index + state.start].height = height;
state.cachePositions[index + state.start].dValue = dValue;
}
});
let startIndex = 0;
if (start) {
startIndex = state.start;
}
const cachedPositionsLen = state.cachePositions.length;
let cumulativeDiffHeight = state.cachePositions[startIndex].dValue;
state.cachePositions[startIndex].dValue = 0;
for (let i = startIndex + 1; i < cachedPositionsLen; ++i) {
const item = state.cachePositions[i];
state.cachePositions[i].top = state.cachePositions[i - 1].bottom;
state.cachePositions[i].bottom = state.cachePositions[i].bottom - cumulativeDiffHeight;
if (item.dValue !== 0) {
cumulativeDiffHeight += item.dValue;
item.dValue = 0;
}
}
const height = state.cachePositions[cachedPositionsLen - 1].bottom;
state.phantomHeight = height;
};
const getStartIndex = (scrollTop = 0) => {
let idx = binarySearch<CachedPosition, number>(
state.cachePositions,
scrollTop,
(currentValue: CachedPosition, targetValue: number) => {
const currentCompareValue = currentValue.bottom;
if (currentCompareValue === targetValue) {
return CompareResult.eq;
}
if (currentCompareValue < targetValue) {
return CompareResult.lt;
}
return CompareResult.gt;
}
) as number;
const targetItem = state.cachePositions[idx];
if (targetItem.bottom < scrollTop) {
idx += 1;
}
return idx;
};
const resetAllVirtualParam = () => {
state.originStartIndex = 0;
state.start = 0;
state.scrollTop = 0;
list.value.scrollTop = 0;
initCachedPosition();
state.phantomHeight = props.height * state.list.length;
};
const handleScrollEvent = () => {
const scrollTop = list.value?.scrollTop as number;
state.start = Math.floor(scrollTop / props.height);
if (end.value > state.list.length) {
emit('scroll');
emit('scroll-bottom');
const { originStartIndex } = state;
const currentIndex = getStartIndex(scrollTop);
if (currentIndex !== originStartIndex) {
state.originStartIndex = currentIndex;
state.start = Math.max(state.originStartIndex - props.bufferSize, 0);
if (end.value >= state.list.length - 1) {
emit('scroll-bottom');
}
}
state.startOffset = scrollTop - (scrollTop % props.height);
emit(scrollTop > state.scrollTop ? 'scroll-up' : 'scroll-down', scrollTop);
state.scrollTop = scrollTop;
};
watch(
() => props.listData,
(val: any[]) => {
state.list = val.slice();
if (state.list.length === val.length) {
initCachedPosition();
updateCachedPosition();
} else {
resetAllVirtualParam();
return;
}
}
);
watch(
() => state.start,
() => {
state.list = props.listData.slice();
if (actualContent.value && state.list.length > 0) {
updateCachedPosition();
}
}
);
return {
...toRefs(state),
list,
phantom,
actualContent,
getTransform,
listHeight,
visibleData,
classes,
getContainerHeight,
......
export interface CachedPosition {
index: number;
top: number;
bottom: number;
height: number;
dValue: number;
}
export enum CompareResult {
eq = 1,
lt,
gt
}
export function binarySearch<T, VT>(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) {
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while (start <= end) {
tempIndex = Math.floor((start + end) / 2);
const midValue = list[tempIndex];
const compareRes: CompareResult = compareFunc(midValue, value);
if (compareRes === CompareResult.eq) {
return tempIndex;
}
if (compareRes === CompareResult.lt) {
start = tempIndex + 1;
} else if (compareRes === CompareResult.gt) {
end = tempIndex - 1;
}
}
return tempIndex;
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册