更新README,添加2025-10-27更新日志;优化层级渲染算法,支持多级分层渲染,修复虚拟列表相关bug;修改LevelRender组件以启用层级渲染;

This commit is contained in:
spe
2025-10-27 10:28:18 +08:00
parent 02dfcbbd73
commit 432cd56542
5 changed files with 485 additions and 167 deletions

View File

@@ -1,7 +1,10 @@
import { director } from "cc";
import { CCFloat } from "cc";
import { Component, Node, Prefab, ScrollView, UITransform, Vec2, _decorator, error, instantiate, isValid, log, v2, v3 } from "cc";
import { EDITOR } from "cc/env";
const { ccclass, property, menu } = _decorator;
let createFlag = 0;
// let createFlag = 0;
/**
* 循环+分帧滑动面板
@@ -17,6 +20,14 @@ export default class RecycleScroll extends Component {
@property(Vec2)
spacing: Vec2 = v2();
/** 上下间隔 */
@property(CCFloat)
paddingTop: number = 0;
/** 子节点缩放 */
@property(CCFloat)
itemScale: number = 1;
/** item数量 */
private _numItems: number = 0;
public get numItems() {
@@ -30,6 +41,8 @@ export default class RecycleScroll extends Component {
this.updateAllItems();
}
frameLoadParama = { fun: null, count: 0, index: 0 };
/** 视图内显示列数 */
private _viewCol: number = 0;
/** 视图内显示行数 */
@@ -48,7 +61,46 @@ export default class RecycleScroll extends Component {
private _isInit: boolean = false;
/** item的index */
private _itemsUUIDToIndex: { [uuid: string]: number } = {};
/**index对应的item */
private _indexToItem: { [index: number]: Node } = {};
/**是否能选中 */
private _isSelectable: boolean = false;
public get isSelectable() {
return this._isSelectable;
}
public set isSelectable(value: boolean) {
this._isSelectable = value;
}
/**选中索引 */
private _selectedIndex: number = -1;
public get selectedIndex() {
return this._selectedIndex;
}
public set selectedIndex(value: number) {
this._selectedIndex = value;
this.updateAllItems();
}
/**能否多选 */
private _isMultiSelect: boolean = false;
public get isMultiSelect() {
return this._isMultiSelect;
}
public set isMultiSelect(value: boolean) {
this._isMultiSelect = value;
}
/**选中索引列表 */
private _selectedIndexes: number[] = [];
public get selectedIndexes() {
return this._selectedIndexes;
}
public set selectedIndexes(value: number[]) {
this._selectedIndexes = value;
this.updateAllItems();
}
// private _itemsIndexToNode: { [index: string]: Node } = {};
private _fleshInterval: number = 0.2;
@@ -59,6 +111,10 @@ export default class RecycleScroll extends Component {
private _itemStartPos: Vec2 = v2();
private _isResizeFinish: boolean = false;
private _lineIndex: number = -1;
private _isLongPress: boolean = false;
public longPressTime: number = 0.5;
public longPressTimer: number = 0;
/** item列表 */
public itemList: Node[] = [];
@@ -68,7 +124,35 @@ export default class RecycleScroll extends Component {
/** item刷新回调 */
public onItemRender(index: number, node: Node) { }
/** item点击回调 */
public onItemLongPress: (index: number, node: Node) => void = null;;
/** item点击回调 */
public onItemClicked(index: number, node: Node) { }
/**选中回调 */
public onItemSelected: (index: number, node: Node) => void = null;
/** 清除某选中 */
public clearSelection(index: number) {
if (this._isMultiSelect && this._selectedIndexes.indexOf(index) != -1) {
this._selectedIndexes.splice(this._selectedIndexes.indexOf(index), 1);
if (this.onItemSelected) {
this.onItemSelected(index, this._indexToItem[index]);
}
}
if (this._isSelectable && this._selectedIndex == index) {
this._selectedIndex = -1;
if (this.onItemSelected) {
this.onItemSelected(index, this._indexToItem[index]);
}
}
}
/** 清除所有选中 */
public clearSelectionAll() {
this._selectedIndexes = [];
this._selectedIndex = -1;
this.updateAllItems();
}
/** 刷新所有item */
public updateAllItems() {
@@ -76,14 +160,35 @@ export default class RecycleScroll extends Component {
}
public scrollToIndexVertical(index: number, duration: number = 0.2) {
const scrollComp = this.node.getComponent(ScrollView);
scrollComp.stopAutoScroll();
const contentUTF = this._getContentUTF();
const p = (this._itemH * index) / (contentUTF.height - this._viewH);
this.node.getComponent(ScrollView).scrollToPercentVertical(1 - p, duration);
const p = (this._itemH * Math.floor(index / this._viewCol)) / (contentUTF.height - this._viewH);
scrollComp.scrollToPercentVertical(1 - p, duration);
}
/** 使指定索引物品处于可视范围 */
public scrollItemVisible(index: number, duration: number = 0.2) {
if (index < 0 || index >= this.numItems) return;
const scrollView = this.node.getComponent(ScrollView);
scrollView.stopAutoScroll();
const itemRow = Math.floor(index / this._viewCol);
const itemPosY = -this._itemH * (itemRow + 0.5);
const itemWposy = itemPosY + this.content.node.worldPosition.y;
const viewWposy = this.content.node.parent.getWorldPosition().y;
const posH = (this._viewH - this._itemH) >> 1;
const deltaY = viewWposy - itemWposy;
if (Math.abs(deltaY) < posH) return;
const sign = deltaY > 0 ? -1 : 1;
const offset = scrollView.getScrollOffset().clone();
offset.y += deltaY + (sign * posH);
scrollView.scrollToOffset(offset, duration);
}
public getItemDirPos(itemIndex: number) {
const x = (itemIndex % this._viewCol) * this._itemW;
const y = -Math.floor(itemIndex / this._viewCol) * this._itemH + (this.spacing.y >> 1);
const y = -Math.floor(itemIndex / this._viewCol) * this._itemH + (this.spacing.y >> 1) - this.paddingTop;
const contentUTF = this._getContentUTF();
const wpos = contentUTF.convertToWorldSpaceAR(v3(x, y));
const parentUTF = this._getContentUTF().node.parent.getComponent(UITransform);
@@ -97,19 +202,30 @@ export default class RecycleScroll extends Component {
protected onLoad(): void {
this.node.on(Node.EventType.SIZE_CHANGED, this.onSizeChange, this);
this._initContentPos();
}
protected onDestroy(): void {
this.node.targetOff(this);
// screen.off(`window-resize`, this.onWindowResize, this);
}
/** 初始化content位置 */
private _initContentPos() {
const scroll = this.node.getComponent(ScrollView);
const content = scroll.content.getComponent(UITransform);
const view = content.node.parent.getComponent(UITransform);
content.node.position = v3(0, (view.height / 2) - (content.height * (1 - content.anchorY)));
}
protected onSizeChange() {
this._isResize = true;
this._initCounter = 0;
this._itemsUUIDToIndex = {};
this._indexToItem = {};
// this.itemList = [];
this._getContentUTF().node.removeAllChildren();
}
protected onDestroy(): void {
this.node.targetOff(this);
// screen.off(`window-resize`, this.onWindowResize, this);
if (!EDITOR)
this._getContentUTF().node.removeAllChildren();
}
private _hideAllItems() {
@@ -122,28 +238,32 @@ export default class RecycleScroll extends Component {
}
/** 初始化 */
private _initialize() {
if (this._isInit) return;
private _initialize(force: boolean = false) {
if (this._isInit && !force) return;
const scroll = this.node.getComponent(ScrollView);
scroll.enabled = false;
this._isInit = true;
const content = this._getContentUTF();
this.content = content;
content.node.removeAllChildren()
if (!EDITOR)
content.node.removeAllChildren()
this.itemList = [];
const viewUTF = content.node.parent.getComponent(UITransform);
this._viewW = viewUTF.width;
this._viewH = viewUTF.height;
const itemData = this.itemPrefab.data.getComponent(UITransform);
this._itemW = itemData.width + this.spacing.x;
this._itemH = itemData.height + this.spacing.y;
this._itemW = itemData.width * this.itemScale + this.spacing.x;
this._itemH = itemData.height * this.itemScale + this.spacing.y;
this._lastPosY = content.node.position.y;
this._viewRow = Math.ceil(this._viewH / this._itemH) + 1;
this._viewCol = Math.floor(this._viewW / this._itemW);
this._viewRow = Math.ceil((this._viewH - this.spacing.y) / this._itemH) + 1;
this._viewCol = Math.floor((this._viewW + this.spacing.x) / this._itemW);
const surplusW = this._viewW - (this._viewCol * this._itemW);
const startPos = v2((-this._viewW >> 1) + (this._itemW >> 1) + (surplusW >> 1), -this._itemH >> 1);
const startPos = v2(
(-this._viewW >> 1) + (this._itemW >> 1) + (surplusW >> 1),
(-this._itemH >> 1) - this.paddingTop
);
this._itemStartPos = startPos;
const cNum = this._viewRow * this._viewCol;
@@ -158,8 +278,50 @@ export default class RecycleScroll extends Component {
const y = -Math.floor(index / this._viewCol) * this._itemH + (this.spacing.y >> 1);
const pos = v3(x + startPos.x, y + startPos.y);
item.setPosition(pos);
item.setScale(v3(this.itemScale, this.itemScale, 1));
item.on(Node.EventType.TOUCH_START, () => {
this._isLongPress = false;
if (this.onItemLongPress) {
this.longPressTimer = Number(setTimeout(() => {
this._isLongPress = true;
this.longPressTimer = 0;
this.onItemLongPress(this._itemsUUIDToIndex[item.uuid], item);
}, this.longPressTime * 1000))
}
})
item.on(Node.EventType.TOUCH_MOVE, () => {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = 0;
}
})
item.on(Node.EventType.TOUCH_END, () => {
if (this._isLongPress) return;
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = 0;
}
this.onItemClicked(this._itemsUUIDToIndex[item.uuid], item);
if (this._isSelectable && this.onItemSelected) {
const lastSelectedIndex = this._selectedIndex;
this._selectedIndex = this._itemsUUIDToIndex[item.uuid];
this.onItemSelected(this._itemsUUIDToIndex[item.uuid], item);
const lastItem = this._indexToItem[lastSelectedIndex];
if (lastItem && !this._isMultiSelect) {
this.onItemSelected(lastSelectedIndex, lastItem);
}
}
if (this._isMultiSelect && this.onItemSelected) {
const selectedIndex = this._itemsUUIDToIndex[item.uuid];
if (this._selectedIndexes.indexOf(selectedIndex) == -1) {
this._selectedIndexes.push(selectedIndex);
} else {
this._selectedIndexes.splice(this._selectedIndexes.indexOf(selectedIndex), 1);
}
this.onItemSelected(selectedIndex, item);
}
}, this);
this.itemList[index] = item;
@@ -168,23 +330,28 @@ export default class RecycleScroll extends Component {
this._updateItem(index, item);
this._itemsUUIDToIndex[item.uuid] = index;
this._indexToItem[index] = item;
createNum++;
if (createNum == cNum) {
scroll.enabled = true;
director.emit('recycle_init_success');
}
}
createFlag++;
// createFlag++;
/** 分帧创建item */
frameLoad(cNum, createFunc, 16, 0, createFlag);
// frameLoad(cNum, createFunc, 16, 0, 0);
this.frameLoadParama = { fun: createFunc, count: cNum, index: 0 };
}
/** 更新centent高度 */
private _updateContentHeight() {
const content = this._getContentUTF();
const col = Math.floor(this._viewW / this._itemW);
const col = Math.floor((this._viewW + this.spacing.x) / this._itemW);
const row = Math.ceil(this.numItems / col);
content.height = row * (this.itemPrefab.data.getComponent(UITransform).height + this.spacing.y) - (this.spacing.y);
const itemH = this.itemPrefab.data.getComponent(UITransform).height * this.itemScale + this.spacing.y;
content.height = (row * itemH) - (this.spacing.y) + (this.paddingTop * 2);
}
/** 获取item在view坐标系的对标 */
@@ -204,11 +371,61 @@ export default class RecycleScroll extends Component {
if (item["needRender"] || force) {
this.onItemRender(index, item);
item["needRender"] = false;
if (this._isSelectable && this.onItemSelected) {
this.onItemSelected(index, item);
}
if (this._isMultiSelect && this.onItemSelected) {
this.onItemSelected(index, item);
}
}
}
}
/**
* 根据索引刷新单个Item
* @param index 要刷新的Item索引
*/
public refreshItem(index: number) {
// 检查索引是否有效
if (index < 0 || index >= this.numItems) {
console.warn(`刷新Item失败: 无效的索引 ${index}`);
return;
}
// 查找对应的Item节点
const item = this.itemList.find(item => this._itemsUUIDToIndex[item.uuid] === index);
if (item) {
// 强制更新Item
this._updateItem(index, item, true);
} else {
console.warn(`刷新Item失败: 未找到索引 ${index} 对应的Item节点`);
}
}
/**
* 刷新所有可见的Items
*/
public refreshVisibleItems() {
this.itemList.forEach(item => {
const index = this._itemsUUIDToIndex[item.uuid];
if (item.active) {
this._updateItem(index, item, true);
}
});
}
public update(dt: number) {
if (this.frameLoadParama?.count) {
const fun = this.frameLoadParama.fun;
fun && fun(this.frameLoadParama.index);
this.frameLoadParama.index++;
if (this.frameLoadParama.index >= this.frameLoadParama.count) {
this.frameLoadParama = null;
}
}
if (this._isResize) {
this._initCounter += dt;
if (this._initCounter >= this._initTimer) {
@@ -228,14 +445,15 @@ export default class RecycleScroll extends Component {
const isDown = dtY < 0;
const viewHalfH = this._viewH >> 1;
const itemHalfH = this._itemH >> 1;
const lineIndex = Math.floor((currY - viewHalfH) / this._itemH);
const lineIndex = Math.floor((currY - viewHalfH + (this.spacing.y >> 1) - this.paddingTop) / (this._itemH));
let isLineChange = this._lineIndex != lineIndex;
if (!isLineChange && !this._isResizeFinish) return;
this._isResizeFinish = false;
this._lineIndex = lineIndex;
const pageHeight = this._itemH * this._viewRow;
const pageLen = this._viewRow * this._viewCol;
const pageIndex = Math.floor((currY - viewHalfH) / pageHeight);
const pageIndex = Math.floor((currY - viewHalfH - this.paddingTop) / pageHeight);
const itemsLen = this.itemList.length;
for (let i = 0; i < itemsLen; ++i) {
const index = i;
@@ -271,34 +489,101 @@ export default class RecycleScroll extends Component {
this._updateItem(currIndex, item);
}
this._itemsUUIDToIndex[item.uuid] = currIndex;
}
}
}
/** 分帧执行 */
function frameLoad(loopTimes: number, func: Function, frameTime: number = 16, __index: number = 0, flag = 0) {
let loop = loopTimes;
let start = new Date().getTime();
let end = 0;
let dt = 0;
for (let i = 0; i < loop; ++i) {
if (flag != createFlag) break;
if (__index >= loop) {
break;
}
try {
func && func(__index);
} catch (e) {
error(e);
}
__index++;
end = new Date().getTime();
dt = end - start;
if (dt > frameTime) {
setTimeout(() => {
frameLoad(loop, func, frameTime, __index, flag);
}, 10);
break;
this._indexToItem[currIndex] = item;
}
}
}
/** 获取item */
getItemByIndex(index: number): Node | undefined {
if (index < 0 || index >= this.numItems) {
return undefined;
}
let uiid = ``;
for (const key in this._itemsUUIDToIndex) {
if (this._itemsUUIDToIndex[key] == index) {
uiid = key;
break;
}
}
for (const node of this.itemList) {
if (node.uuid == uiid) {
return node;
}
}
return undefined;
}
/** 强制显示指定索引的Item
* @param index 索引
*/
forcedDisplayItemByIndex(index: number) {
// 获取滚动视图组件
const scrollView = this.getComponent(ScrollView);
if (!scrollView) {
error("RecycleScroll: 找不到 ScrollView 组件");
return;
}
//停止滑动滑动视图的滑动和惯性滑动
scrollView.stopAutoScroll();
// 检查索引是否有效
if (index < 0 || index >= this._numItems) {
error("RecycleScroll: 无效的索引值");
return;
}
// 计算目标位置
const content = scrollView.content;
const viewHeight = this.node.getComponent(UITransform).height;
const contentHeight = content.getComponent(UITransform).height;
const contentAnchor = content.getComponent(UITransform).anchorPoint;
// 处理ScrollView锚点问题
const scrollViewAnchor = scrollView.node.getComponent(UITransform).anchorPoint;
let initPosY = 0 + scrollViewAnchor.y * viewHeight;
// 计算目标行数和目标Y坐标
const targetRow = Math.floor(index / this._viewCol);
let targetY = targetRow * this._itemH + initPosY;
content.setPosition(v3(content.position.x, targetY, content.position.z));
// 计算content底部的y坐标
const contentBottomY = content.worldPosition.y - contentHeight * contentAnchor.y;
//计算scrollview底部的坐标
const scrollViewBottomY = this.node.worldPosition.y - viewHeight * scrollViewAnchor.y;
//如果content底部超出scrollview底部,则将content位置调整到底部对齐
if (contentBottomY > scrollViewBottomY) {
content.setPosition(v3(content.position.x, targetY - (contentBottomY - scrollViewBottomY), content.position.z));
}
// 强制更新一次显示
this.updateAllItems();
}
/** 检查索引是否正在渲染
* @param index
* @returns
*/
isRanderingItemByIndex(index: number) {
// 检查索引是否有效
if (index < 0 || index >= this._numItems) {
return false;
}
for (const key in this._itemsUUIDToIndex) {
if (this._itemsUUIDToIndex[key] == index) {
return true;
}
}
return false;
}
}