[inula-dev-tools]<feat> 显示列表组件合入

This commit is contained in:
13659257719 2023-11-07 10:56:44 +08:00
parent cc9c10b84b
commit ee52d818dc
3 changed files with 241 additions and 0 deletions

View File

@ -0,0 +1,80 @@
/*
* Copyright (c) 2023 Huawei Technologies Co.,Ltd.
*
* openInula is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
/**
*
*
*/
export default class ItemMap<T> {
// 不要用 indexOf 进行位置计算,它会遍历数组
private lastRenderItemToIndexMap: Map<T, number>;
constructor() {
this.lastRenderItemToIndexMap = new Map();
}
public calculateReSortedItems(nextItems: T[]): (T|undefined)[] {
if (this.lastRenderItemToIndexMap.size === 0) {
nextItems.forEach((item, index) => {
this.lastRenderItemToIndexMap.set(item, index);
});
return nextItems;
}
const nextRenderItems: T[] = [];
const length = nextItems.length;
const nextRenderItemToIndexMap = new Map<T, number>();
const addItems = [];
// 遍历 nextItems 找到复用 item 和新增 item
nextItems.forEach(item => {
const lastIndex = this.lastRenderItemToIndexMap.get(item);
// 处理旧 item
if (lastIndex !== undefined) {
// 使用上一次的位置
nextRenderItems[lastIndex] = item;
// 记录位置
nextRenderItemToIndexMap.set(item, lastIndex);
} else {
// 记录新的 item
addItems.push(item);
}
});
// 处理新增 item翻转数组后面在调用 pop 时拿到的时最后一个,以确保顺序
addItems.reverse();
for (let i = 0; i < length; i++) {
// 优先将新增 item 放置在空位置上
if (!nextRenderItems[i]) {
const item = addItems.pop();
nextRenderItems[i] = item;
nextRenderItemToIndexMap.set(item, i);
}
}
// 剩余新 item 补在数组后面
for (let i = addItems.length - 1; i >= 0; i--) {
const item = addItems[i];
nextRenderItemToIndexMap.set(item, nextRenderItems.length);
nextRenderItems.push(item);
}
// 如果 nextRenderItems 中存在空 indexnextItems 已经耗尽,不用处理
// 确保新旧数组中 item 的 index 值不会发生变化
this.lastRenderItemToIndexMap = nextRenderItemToIndexMap;
return nextRenderItems;
}
}

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2023 Huawei Technologies Co.,Ltd.
*
* openInula is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
.container {
position: relative;
overflow-y: auto;
height: 100%;
width: 100%;
}
.item {
position: absolute;
width: 100%;
}

View File

@ -0,0 +1,135 @@
/*
* Copyright (c) 2023 Huawei Technologies Co.,Ltd.
*
* openInula is licensed under Mulan PSL v2.
* You can use this software according to the terms and conditions of the Mulan PSL v2.
* You may obtain a copy of Mulan PSL v2 at:
*
* http://license.coscl.org.cn/MulanPSL2
*
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
* EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
* MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
* See the Mulan PSL v2 for more details.
*/
/**
*
* data scrollToItem
*/
import { useState, useRef, useEffect, useMemo } from 'openinula';
import styles from './VList.less';
import ItemMap from './ItemMap';
import { debounceFunc } from '../../utils/publicUtil';
interface IProps<T extends { id: number | string }> {
data: T[];
maxDeep: number;
width: number; // 暂时未用到,当需要支持横向滚动时使用
height: number; // VList 的高度
children?: any; // inula 组件
itemHeight: number;
scrollToItem?: T; // 滚动到指定项位置,如果该项在可见区域内,不滚动,如果补在,则滚动到中间位置
onRendered: (renderInfo: RenderInfoType<T>) => void;
filter?: (data: T) => boolean; // false 表示该行不显示
}
export type RenderInfoType<T> = {
visibleItems: T[];
}
function parseTranslate<T>(data: T[], itemHeight: number) {
const map = new Map<T, number>();
data.forEach((item, index) => {
map.set(item, index * itemHeight);
})
return map;
}
export function VList<T extends { id: number | string }>(props: IProps<T>) {
const { data, maxDeep, height, width, children, itemHeight, scrollToItem, onRendered } = props;
const [scrollTop, setScrollTop] = useState(Math.max(data.indexOf(scrollToItem), 0) * itemHeight);
const renderInfoRef: { current: RenderInfoType<T> } = useRef({
visibleItems: [],
});
const [indentationLength, setIndentationLength] = useState(0);
// 每个 item 的 translateY 值固定不变
const itemToTranslateYMap = useMemo(() => parseTranslate(data, itemHeight), [data]);
const itemIndexMap = useMemo(() => new ItemMap<T>(), []);
const containerRef = useRef<HTMLDivElement>();
useEffect(() => {
onRendered(renderInfoRef.current);
});
useEffect(() => {
debounceFunc(() => setIndentationLength(Math.min(12, Math.round(width / (2 * maxDeep)))));
}, [width]);
useEffect(() => {
if (scrollToItem) {
const renderInfo = renderInfoRef.current;
// 在显示区域,不滚动
if (!renderInfo.visibleItems.includes(scrollToItem)) {
const index = data.indexOf(scrollToItem);
// 显示在页面中间
const top = Math.max(index * itemHeight - height / 2, 0);
containerRef.current.scrollTo({ top: top });
}
}
}, [scrollToItem]);
// 滚动事件会频繁触发,通过框架提供的代理会有大量计算寻找 dom 元素,直接绑定到原生事件上减少计算量
useEffect(() => {
const handleScroll = event => {
const scrollTop = event.target.scrollTop;
setScrollTop(scrollTop);
};
const container = containerRef.current;
container.addEventListener('scroll', handleScroll);
return () => {
container.removeEventListener('scroll', handleScroll);
};
}, []);
const totalHeight = itemHeight * data.length;
const maxIndex = data.length; // slice 截取渲染 item 数组时最大位置不能超过自然长度
// 第一个可见 item index
const firstInViewItemIndex = Math.floor(scrollTop / itemHeight);
// 可见区域前最多冗余 4 个 item
const startRenderIndex = Math.max(firstInViewItemIndex - 4, 0); // index 不能小于 0
// 最多可见数量
const maxInViewCount = Math.floor(height / itemHeight);
// 最后可见 item index
const lastInViewIndex = Math.min(firstInViewItemIndex + maxInViewCount, maxIndex);
// 记录可见 items
renderInfoRef.current.visibleItems = data.slice(firstInViewItemIndex, lastInViewIndex);
// 可见区域后冗余 4 个 item
const lastRenderIndex = Math.min(lastInViewIndex + 4, maxIndex);
// 需要渲染的 item
const renderItems = data.slice(startRenderIndex, lastRenderIndex);
// 给 items 重新排序,确保未移出渲染数组的 item 在新的渲染数组中位置不变,这样在 diff 算法比较后,这部分的 dom 不会发生更新
const nextRenderList = itemIndexMap.calculateReSortedItems(renderItems);
const list = nextRenderList.map((item, index) => {
if (!item) {
return null;
}
return (
<div
key={String(i)} // 固定 key 值,这样就只会更新 translateY 的值
className={styles.item}
style={{ transform: `translateY(${itemToTranslateYMap.get(item)}px)` }}
>
{children(item,indentationLength)}
</div>
);
});
return (
<div ref={containerRef} className={styles.container}>
{list}
<div style={{ marginTop: totalHeight }}></div>
</div>
);
}