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; /** * 循环+分帧滑动面板 */ @ccclass('RecycleScroll') @menu("性能优化/RecycleScroll") export default class RecycleScroll extends Component { /** item预制 */ @property(Prefab) itemPrefab: Prefab = null; /** item间隔 */ @property(Vec2) spacing: Vec2 = v2(); /** 上下间隔 */ @property(CCFloat) paddingTop: number = 0; /** 子节点缩放 */ @property(CCFloat) itemScale: number = 1; /** item数量 */ private _numItems: number = 0; public get numItems() { return this._numItems; } public set numItems(value: number) { this._numItems = value; this._hideAllItems(); this._initialize(); this._updateContentHeight(); this.updateAllItems(); } frameLoadParama = { fun: null, count: 0, index: 0 }; /** 视图内显示列数 */ private _viewCol: number = 0; /** 视图内显示行数 */ private _viewRow: number = 0; /** 视图窗宽 */ private _viewW: number = 0; /** 视图窗高 */ private _viewH: number = 0; /** item格子宽 */ private _itemW: number = 0; /** item格子高 */ private _itemH: number = 0; /** content上一次y坐标 */ private _lastPosY: number = 0; /** 是否已初始化 */ 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; private _fleshCounter: number = 0; private _initTimer: number = 0.05; private _initCounter: number = 0; private _isResize: boolean = false; 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[] = []; /** item父节点 */ public content: UITransform = null; /** 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() { this.itemList.forEach((item: Node) => this._updateItem(this._itemsUUIDToIndex[item.uuid], item, true)); } public scrollToIndexVertical(index: number, duration: number = 0.2) { const scrollComp = this.node.getComponent(ScrollView); scrollComp.stopAutoScroll(); const contentUTF = this._getContentUTF(); 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) - this.paddingTop; const contentUTF = this._getContentUTF(); const wpos = contentUTF.convertToWorldSpaceAR(v3(x, y)); const parentUTF = this._getContentUTF().node.parent.getComponent(UITransform); const itemInViewPos = parentUTF.convertToNodeSpaceAR(wpos); let horizon = 0; let vertical = 0; horizon = itemInViewPos.x < -this._viewW / 2 ? -1 : (itemInViewPos.x > this._viewW / 2 ? 1 : 0); vertical = itemInViewPos.y < -this._viewH / 2 ? -1 : (itemInViewPos.y > this._viewH / 2 ? 1 : 0); return [horizon, vertical]; } 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 = []; if (!EDITOR) this._getContentUTF().node.removeAllChildren(); } private _hideAllItems() { this.itemList.forEach((item: Node, index: number) => item.active = false); } /** 获取content */ private _getContentUTF() { return this.node.getComponent(ScrollView).content.getComponent(UITransform); } /** 初始化 */ 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; 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.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.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) - this.paddingTop ); this._itemStartPos = startPos; const cNum = this._viewRow * this._viewCol; log(`实例化数量:${cNum}`); let createNum = 0; const createFunc = (index: number) => { if (!isValid(content)) return; //异步创建,创建完回来父节点有可能已经被销毁 const item = instantiate(this.itemPrefab); item.parent = content.node; const x = (index % this._viewCol) * this._itemW; 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; item["needRender"] = true; 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++; /** 分帧创建item */ // 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.spacing.x) / this._itemW); const row = Math.ceil(this.numItems / col); 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坐标系的对标 */ private _getPosInView(item: Node) { const content = this._getContentUTF(); const viewUTF = content.node.parent.getComponent(UITransform); const wpos = content.convertToWorldSpaceAR(item.position); const lpos = viewUTF.convertToNodeSpaceAR(wpos); return lpos; } /** 更新item */ private _updateItem(index: number, item: Node, force = false) { const isShow = index >= 0 && index < this.numItems; item.active = isShow; if (isShow) { 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) { this._isInit = false; this._isResize = false; this.numItems = this._numItems; this._isResizeFinish = true; } return; } const content = this._getContentUTF(); const currY = content.node.position.y; const dtY = currY - this._lastPosY; this._lastPosY = currY; this._fleshCounter += dt; if (dtY == 0 && !this._isResizeFinish) return; const isDown = dtY < 0; const viewHalfH = this._viewH >> 1; const itemHalfH = this._itemH >> 1; 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 - this.paddingTop) / pageHeight); const itemsLen = this.itemList.length; for (let i = 0; i < itemsLen; ++i) { const index = i; const item = this.itemList[i]; const x = (index % this._viewCol) * this._itemW; const y = -Math.floor(index / this._viewCol) * this._itemH + (this.spacing.y >> 1); const pos = v3(x + this._itemStartPos.x, y + this._itemStartPos.y - pageIndex * (pageHeight)); item.setPosition(pos); const posInView = this._getPosInView(item); const lastIndex = this._itemsUUIDToIndex[item.uuid]; let currIndex = pageIndex * pageLen + i; if (!isDown) { if (posInView.y >= (viewHalfH + itemHalfH)) { item.setPosition(v3(item.position.x, item.position.y - (this._viewRow * this._itemH))); currIndex += itemsLen; } } else { if (posInView.y >= viewHalfH + itemHalfH) { item.setPosition(v3(item.position.x, item.position.y - (this._viewRow * this._itemH))); currIndex += itemsLen; } if (isLineChange) { const posInView = this._getPosInView(item); if (posInView.y <= -(viewHalfH + itemHalfH)) { item.setPosition(v3(item.position.x, item.position.y + (this._viewRow * this._itemH))); currIndex -= itemsLen; } } } if (currIndex != lastIndex) { item["needRender"] = true; this._updateItem(currIndex, item); } this._itemsUUIDToIndex[item.uuid] = currIndex; 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; } }