效果预览
该虚拟列表项可为动态高度,在浏览过程中根据用户事件调整高度(如展开,收起等)也能满足。
实现步骤
一: 获取元素高度:
- 使用相关API(querySelector 等)获取, 然后将其高度缓存起来:
- 在设置元素高度时,重新渲染列表
const id = useId();
const idToSelect = "wrap" + id
const updateSize = () => {
getRectSizeASync(`#${idToSelect}`).then(rect => {
setSize(index, rect.height);
})
}
useEffect(() => {
updateSize();
}, []);
const sizeMap = useRef(new Map());
const setSize = useCallback((index, size) => {
sizeMap.current.set(index, size);
setRefreshCount(val => val + 1);
}, []);
const getSize = useCallback((index) => {
return sizeMap.current.get(index) || defaultHeight;
}, []);
二: 确定视口范围内显示的元素索引, startIndex 和 endIndex:
- startIndex: 从 0 开始查找,累加元素高度,直到总和大于滚动高度
- endIndex: 与 startIndex 类似,总和应大于滚动高度加上滚动容器的高度
const getStartIndex = useCallback((scrollTop) => {
let sum = 0;
const target = list.findIndex((_, index) => {
sum += getSize(index);
return sum > scrollTop;
});
return Math.max(0, target - 3); // 3 为向上缓冲数量
},[list]);
const getEndIndex = useCallback((startIndex, scrollTop) => {
let sum = 0;
const target = list.findIndex((_, index) => {
sum += getSize(index);
return sum > scrollTop + height; // height 为滚动容器高度
});
if (!~target) return list.length;
return Math.max(startIndex, Math.min(list.length, target + 3)) // 3 为向下缓冲数量
}, [list, height]);
二、获取元素位置
- 第一个元素的位置即为0
- 非第一个元素:前一元素的偏移值再加上其高度
const offsetMap = useRef(new Map());
const getOffset = useCallback(index => {
if (index === 0) {
offsetMap.current.set(index, 0);
return 0;
}
const offset = offsetMap.current.get(index - 1) + getSize(index - 1);
offsetMap.current.set(index, offset);
return offset;
}, [list]);
三、增加列表容器
- 列表容器高度为从 0 到 maxEndIndex 的元素高度之和,以此来撑开列表在滚动容器的大小:
const getWrapHeight = () => {
const newWrapHeight = list.slice(0, maxEndIndex.current).reduce((sum, _, index) => {
return sum + getSize(index);
}, 0);
wrapHeight.current = newWrapHeight;
return newWrapHeight
}
四、增加滚动监听事件,每次滚动更新 scrollTop 值,通过该值计算新的 startIndex 和 endIndex 即可。
完整代码
const ItemWrap = ({ setSize, offset, item: Item, list, index, updateLastFoldIndex }) => {
const id = useId();
const idToSelect = "wrap" + id
const updateSize = () => {
getRectSizeASync(`#${idToSelect}`).then(rect => {
setSize(index, rect.height);
})
}
useEffect(() => {
updateSize();
}, []);
return <View id={idToSelect} style={{
position: "absolute",
width: "100%",
transform: `translateY(${offset}px)`,
overflow: "hidden"
}}>
<Item
data={list}
index={index}
updateSize={updateSize}
updateLastFoldIndex={updateLastFoldIndex}
></Item>
</View>
}
const VirtualList = ({
item: Item,
itemData: list,
defaultHeight=100,
}: Props<T, U>) => {
const listId = useId();
const idToSelect = "list" + listId;
const [scrollTop, setScrollTop] = useState(0);
const [refreshCount, setRefreshCount] = useState(0);
const [height, setHeight] = useState(0);
const maxEndIndex = useRef(0);
const wrapHeight = useRef(0);
const sizeMap = useRef(new Map());
const offsetMap = useRef(new Map());
const lastFoldIndex = useRef(-1);
const getStartIndex = useCallback((scrollTop) => {
let sum = 0;
const target = list.findIndex((_, index) => {
sum += getSize(index);
return sum > scrollTop;
});
return Math.max(0, target - 3);
},[list]);
const getEndIndex = useCallback((startIndex, scrollTop) => {
let sum = 0;
const target = list.findIndex((_, index) => {
sum += getSize(index);
return sum > scrollTop + height;
});
if (!~target) return list.length;
return Math.max(startIndex, Math.min(list.length, target + 3))
}, [list, height]);
const getSize = useCallback((index) => {
return sizeMap.current.get(index) || defaultHeight
}, []);
const setSize = useCallback((index, size) => {
sizeMap.current.set(index, size);
setRefreshCount(val => val + 1);
}, []);
const getOffset = useCallback(index => {
if (index === 0) {
offsetMap.current.set(index, 0);
return 0;
}
const offset = offsetMap.current.get(index - 1) + getSize(index - 1);
offsetMap.current.set(index, offset);
return offset;
}, [list]);
let startIndex = getStartIndex(scrollTop);
const endIndex = getEndIndex(startIndex, scrollTop);
if (endIndex > maxEndIndex.current) {
maxEndIndex.current = endIndex
}
if (~lastFoldIndex.current) {
const index = lastFoldIndex.current
startIndex = Math.max(0, Math.min(startIndex, index));
}
const getWrapHeight = () => {
const newWrapHeight = list.slice(0, maxEndIndex.current).reduce((sum, _, index) => {
return sum + getSize(index);
}, 0);
wrapHeight.current = newWrapHeight;
return newWrapHeight
}
useEffect(() => {
getRectSizeASync(`#${idToSelect}`).then(rect => {
if (rect.height) setHeight(rect.height)
})
}, []);
return <ScrollView
id={idToSelect}
scrollY
style={{
height: '100%',
width: '100%',
flex: 1
}}
onScroll={({ detail: { scrollTop } }) => {
setScrollTop(scrollTop);
lastFoldIndex.current = -1;
}}
>
<View className="wrap" style={{
height: getWrapHeight() + 40,
position: "relative",
}}>
{
list.slice(startIndex, endIndex).map((_, index) =>
<ItemWrap
item={Item}
index={index + startIndex}
list={list}
key={index + startIndex}
offset={getOffset(index + startIndex)}
updateLastFoldIndex={(i) => lastFoldIndex.current = i}
setSize={setSize}
></ItemWrap>).reverse()
}
</View>
</ScrollView>
}
export default VirtualList;