import { _decorator, Component, Node, UITransform, instantiate, Prefab, EventTouch, Vec3, math, Mask, Vec2, tween, Tween, input, Input, Enum, } from 'cc'; import { VScrollViewItem } from './VScrollViewItem'; import { Widget } from 'cc'; const { ccclass, property, menu } = _decorator; class InternalNodePool { private pools: Map = new Map(); private prefabs: Prefab[] = []; constructor(prefabs: Prefab[]) { this.prefabs = prefabs; prefabs.forEach((_, index) => { this.pools.set(index, []); }); } get(typeIndex: number): Node { const pool = this.pools.get(typeIndex); if (!pool) { console.error(`[VScrollView NodePool] 类型 ${typeIndex} 不存在`); return null; } if (pool.length > 0) { const node = pool.pop()!; node.active = true; return node; } const newNode = instantiate(this.prefabs[typeIndex]); return newNode; } put(node: Node, typeIndex: number) { if (!node) return; const pool = this.pools.get(typeIndex); if (!pool) { console.error(`[VScrollView NodePool] 类型 ${typeIndex} 不存在`); node.destroy(); return; } node.active = false; node.removeFromParent(); pool.push(node); } clear() { this.pools.forEach(pool => { pool.forEach(node => node.destroy()); pool.length = 0; }); this.pools.clear(); } getStats() { const stats: any = {}; this.pools.forEach((pool, type) => { stats[`type${type}`] = pool.length; }); return stats; } } export type RenderItemFn = (node: Node, index: number) => void; export type ProvideNodeFn = (index: number) => Node | Promise; export type OnItemClickFn = (node: Node, index: number) => void; export type OnItemLongPressFn = (node: Node, index: number) => void; export type PlayItemAppearAnimationFn = (node: Node, index: number) => void; export type GetItemHeightFn = (index: number) => number; export type GetItemTypeIndexFn = (index: number) => number; // 刷新状态回调 export type OnRefreshStateChangeFn = (state: RefreshState, offset: number) => void; // 加载更多状态回调 export type OnLoadMoreStateChangeFn = (state: LoadMoreState, offset: number) => void; // 分页吸附回调 export type OnPageChangeFn = (pageIndex: number) => void; export enum ScrollDirection { VERTICAL = 0, HORIZONTAL = 1, } // 添加刷新状态枚举 export enum RefreshState { IDLE = 0, // 空闲状态 PULLING = 1, // 正在拉动(未达到触发阈值) READY = 2, // 达到触发阈值,松手即可刷新 REFRESHING = 3, // 正在刷新中 COMPLETE = 4, // 刷新完成 } export enum LoadMoreState { IDLE = 0, // 空闲状态 PULLING = 1, // 正在上拉(未达到触发阈值) READY = 2, // 达到触发阈值,松手即可加载 LOADING = 3, // 正在加载中 COMPLETE = 4, // 加载完成 NO_MORE = 5, // 没有更多数据 } @ccclass('VirtualScrollView') @menu('2D/VirtualScrollView(虚拟滚动列表)') export class VirtualScrollView extends Component { @property({ type: Node, displayName: '容器节点', tooltip: 'content 容器节点(在 Viewport 下)' }) public content: Node | null = null; @property({ displayName: '启用虚拟列表', tooltip: '是否启用虚拟列表模式(关闭则仅提供滚动功能)', }) public useVirtualList: boolean = true; @property({ type: Enum(ScrollDirection), displayName: '滚动方向', tooltip: '滚动方向:纵向(向上)或横向(向左)', }) public direction: ScrollDirection = ScrollDirection.VERTICAL; @property({ type: Prefab, displayName: '子项预制体', tooltip: '可选:从 Prefab 创建 item(等大小模式)', visible(this: VirtualScrollView) { return this.useVirtualList && !this.useDynamicSize; }, }) public itemPrefab: Prefab | null = null; @property({ displayName: '不等大小模式', tooltip: '启用不等大小模式', visible(this: VirtualScrollView) { return this.useVirtualList; }, }) public useDynamicSize: boolean = false; @property({ displayName: '自动居中布局', tooltip: '当子项数量少于行/列数时,自动居中显示(适用于等大小模式)', visible(this: VirtualScrollView) { return this.useVirtualList && !this.useDynamicSize; }, }) public autoCenter: boolean = false; @property({ displayName: '启用分页吸附', tooltip: '滚动结束后自动吸附到最近的 item 位置', }) public enablePageSnap: boolean = false; @property({ displayName: '===吸附动画时长', tooltip: '吸附动画的持续时间(秒)', range: [0.1, 1, 0.05], visible(this: VirtualScrollView) { return this.enablePageSnap; }, }) public pageSnapDuration: number = 0.15; @property({ displayName: '===切页距离比例', tooltip: '滑动距离超过页面尺寸的此比例时翻页(0.1-0.5)', range: [0.1, 0.5, 0.05], visible(this: VirtualScrollView) { return this.enablePageSnap; }, }) public pageSnapDistanceRatio: number = 0.15; @property({ displayName: '===吸附触发速度', tooltip: '惯性速度低于此值时触发吸附(越大越早吸附)', range: [50, 3000, 10], visible(this: VirtualScrollView) { return this.enablePageSnap; }, }) public pageSnapTriggerVelocity: number = 600; @property({ displayName: '不等高模式(已废弃,仅保持兼容)', tooltip: '启用不等高模式(已废弃,仅保持兼容,请使用 useDynamicSize )', }) public useDynamicHeight: boolean = false; @property({ displayName: '列数(已废弃,仅保持兼容)', tooltip: '列数(已废弃,请使用 gridCount 替代,仅保持兼容)', }) public columns: number = 1; @property({ displayName: '列间距(已废弃,仅保持兼容)', tooltip: '列间距(已废弃,请使用 gridSpacing 替代,仅保持兼容)', }) public columnSpacing: number = 0; @property({ type: [Prefab], displayName: '子项预制体数组', tooltip: '不等大小模式:预先提供的子项预制体数组(可在编辑器拖入)', visible(this: VirtualScrollView) { return this.useVirtualList && this.useDynamicSize; }, }) public itemPrefabs: Prefab[] = []; private itemMainSize: number = 100; private itemCrossSize: number = 100; @property({ displayName: '行/列数', tooltip: '纵向模式为列数,横向模式为行数', range: [1, 10, 1], visible(this: VirtualScrollView) { return this.useVirtualList && !this.useDynamicSize; }, }) public gridCount: number = 1; @property({ displayName: '副方向间距', tooltip: '主方向垂直方向的间距(像素)', range: [0, 1000, 1], visible(this: VirtualScrollView) { return this.useVirtualList && !this.useDynamicSize; }, }) public gridSpacing: number = 0; @property({ displayName: '主方向间距', tooltip: '主方向的间距(像素)', range: [0, 1000, 1], visible(this: VirtualScrollView) { return this.useVirtualList; }, }) public spacing: number = 0; @property({ displayName: '头部间距', tooltip: '列表头部的额外间距(纵向为顶部,横向为左侧)', range: [0, 1000, 1], visible(this: VirtualScrollView) { return this.useVirtualList; }, }) public headerSpacing: number = 0; @property({ displayName: '尾部间距', tooltip: '列表尾部的额外间距(纵向为底部,横向为右侧)', range: [0, 1000, 1], visible(this: VirtualScrollView) { return this.useVirtualList; }, }) public footerSpacing: number = 0; @property({ displayName: '总条数', tooltip: '总条数(可在运行时 setTotalCount 动态修改)', range: [0, 1000, 1], visible(this: VirtualScrollView) { return this.useVirtualList; }, }) public totalCount: number = 50; @property({ displayName: '额外缓冲', tooltip: '额外缓冲(可视区外多渲染几条,避免边缘复用闪烁)', range: [0, 10, 1], visible(this: VirtualScrollView) { return this.useVirtualList; }, }) public buffer: number = 1; @property({ displayName: '启用下拉刷新', tooltip: '是否启用下拉刷新功能', }) public enablePullRefresh: boolean = false; @property({ displayName: '===下拉触发距离', tooltip: '下拉多少距离触发刷新(像素)', range: [50, 500, 10], visible(this: VirtualScrollView) { return this.enablePullRefresh; }, }) public pullRefreshThreshold: number = 100; @property({ displayName: '===下拉最大距离', tooltip: '下拉的最大阻尼距离(像素)', range: [100, 1000, 10], visible(this: VirtualScrollView) { return this.enablePullRefresh; }, }) public pullRefreshMaxOffset: number = 150; @property({ displayName: '启用上拉加载', tooltip: '是否启用上拉加载更多功能', }) public enableLoadMore: boolean = false; @property({ displayName: '===上拉触发距离', tooltip: '距离底部多少距离触发加载(像素)', range: [50, 500, 10], visible(this: VirtualScrollView) { return this.enableLoadMore; }, }) public loadMoreThreshold: number = 100; @property({ displayName: '===上拉最大距离', tooltip: '上拉的最大阻尼距离(像素)', range: [100, 1000, 10], visible(this: VirtualScrollView) { return this.enableLoadMore; }, }) public loadMoreMaxOffset: number = 150; @property({ displayName: '拉动阻尼系数', tooltip: '拉动时的阻尼系数(0-1),越小越难拉', range: [0.1, 1, 0.05], visible(this: VirtualScrollView) { return this.enablePullRefresh || this.enableLoadMore; }, }) public pullDampingRate: number = 0.5; @property({ displayName: '像素对齐', tooltip: '是否启用像素对齐' }) public pixelAlign: boolean = true; @property({ displayName: '惯性阻尼系数', tooltip: '指数衰减系数,越大减速越快', range: [0, 10, 0.5], }) public inertiaDampK: number = 1; @property({ displayName: '弹簧刚度', tooltip: '越界弹簧刚度 K(建议 120–240)' }) public springK: number = 150.0; @property({ displayName: '弹簧阻尼', tooltip: '越界阻尼 C(建议 22–32)' }) public springC: number = 26.0; @property({ displayName: '速度阈值', tooltip: '速度阈值(像素/秒),低于即停止' }) public velocitySnap: number = 5; @property({ displayName: '速度窗口', tooltip: '速度估计窗口(秒)' }) public velocityWindow: number = 0.08; @property({ displayName: '最大惯性速度', tooltip: '最大惯性速度(像素/秒)' }) public maxVelocity: number = 6000; @property({ displayName: 'iOS减速曲线', tooltip: '是否使用 iOS 风格的减速曲线' }) public useIOSDecelerationCurve: boolean = true; public renderItemFn: RenderItemFn | null = null; public provideNodeFn: ProvideNodeFn | null = null; public onItemClickFn: OnItemClickFn | null = null; public onItemLongPressFn: OnItemLongPressFn | null = null; public playItemAppearAnimationFn: PlayItemAppearAnimationFn | null = null; public getItemHeightFn: GetItemHeightFn | null = null; public getItemTypeIndexFn: GetItemTypeIndexFn | null = null; public onRefreshStateChangeFn: OnRefreshStateChangeFn | null = null; public onLoadMoreStateChangeFn: OnLoadMoreStateChangeFn | null = null; public onPageChangeFn: OnPageChangeFn | null = null; private _viewportSize = 0; private _contentSize = 0; private _boundsMin = 0; private _boundsMax = 0; private _velocity = 0; private _isTouching = false; private _velSamples: { t: number; delta: number }[] = []; private _slotNodes: Node[] = []; private _slots = 0; private _slotFirstIndex = 0; private _itemSizes: number[] = []; private _prefixPositions: number[] = []; private _prefabSizeCache: Map = new Map(); private _nodePool: InternalNodePool | null = null; private _slotPrefabIndices: number[] = []; private _needAnimateIndices: Set = new Set(); private _initSortLayerFlag: boolean = true; private _scrollTween: any = null; private _tmpMoveVec2 = new Vec2(); // 私有状态变量 private _refreshState: RefreshState = RefreshState.IDLE; private _loadMoreState: LoadMoreState = LoadMoreState.IDLE; private _pullOffset: number = 0; // 当前下拉偏移量 private _loadOffset: number = 0; // 当前上拉偏移量 private _isRefreshing: boolean = false; private _isLoadingMore: boolean = false; private _hasMore: boolean = true; // 是否还有更多数据 // 分页吸附相关 private _currentPageIndex: number = 0; private _pageStartPos: number = 0; // 记录触摸开始时的位置 private _touchStartPos: Vec2 = new Vec2(); private _hasDeterminedScrollDirection: boolean = false; private _shouldBlockParent: boolean = false; private _scrollDirectionThreshold: number = 15; // 滑动阈值(像素) private _scrollAngleThreshold: number = 30; // 角度阈值(度) // 等大小模式下,从 content 子节点获取的模板节点 private _templateNode: Node | null = null; private get _contentTf(): UITransform { this.content = this._getContentNode(); return this.content!.getComponent(UITransform)!; } private get _viewportTf(): UITransform { return this.node.getComponent(UITransform)!; } private _getContentNode(): Node { if (!this.content) { console.warn(`[VirtualScrollView] :${this.node.name} 请在属性面板绑定 content 容器节点`); this.content = this.node.getChildByName('content'); } return this.content; } private _isVertical(): boolean { return this.direction === ScrollDirection.VERTICAL; } private _getViewportMainSize(): number { return this._isVertical() ? this._viewportTf.height : this._viewportTf.width; } private _getContentMainPos(): number { return this._isVertical() ? this.content!.position.y : this.content!.position.x; } private _setContentMainPos(pos: number) { if (!Number.isFinite(pos)) return; if (this.pixelAlign) pos = Math.round(pos); const p = this.content!.position; if (this._isVertical()) { if (pos === p.y) return; this.content!.setPosition(p.x, pos, p.z); } else { if (pos === p.x) return; this.content!.setPosition(pos, p.y, p.z); } } async start() { this.content = this._getContentNode(); if (!this.content) return; const mask = this.node.getComponent(Mask); if (!mask) console.warn('[VirtualScrollView] 建议在视窗节点挂一个 Mask 组件用于裁剪'); this.gridCount = Math.max(1, Math.round(this.gridCount)); if (!this.useVirtualList) { this._viewportSize = this._getViewportMainSize(); this._contentSize = this._isVertical() ? this._contentTf.height : this._contentTf.width; if (this._isVertical()) { this._boundsMin = 0; this._boundsMax = Math.max(0, this._contentSize - this._viewportSize); } else { this._boundsMin = -Math.max(0, this._contentSize - this._viewportSize); this._boundsMax = 0; } this._bindTouch(); this._bindGlobalTouch(); return; } // 等大小模式:如果没有预制体但 content 下有子节点,保存第一个子节点作为模板 if (!this.useDynamicSize && !this.itemPrefab && this.content.children.length > 0) { this._templateNode = this.content.children[0]; this._templateNode.removeFromParent(); // 只移除,不销毁 } this.content.removeAllChildren(); this._viewportSize = this._getViewportMainSize(); //兼容废弃属性 if (this.useDynamicHeight) { this.useDynamicSize = true; } //兼容之前版本的参数 if (this.columns && this.direction === ScrollDirection.VERTICAL) { this.gridCount = this.columns; } if (this.columnSpacing && this.direction === ScrollDirection.VERTICAL) { this.gridSpacing = this.columnSpacing; } if (this.useDynamicSize) await this._initDynamicSizeMode(); else await this._initFixedSizeMode(); this._bindTouch(); this._bindGlobalTouch(); } onDestroy() { input.off(Input.EventType.TOUCH_END, this._onGlobalTouchEnd, this); input.off(Input.EventType.TOUCH_CANCEL, this._onGlobalTouchEnd, this); this.node.off(Node.EventType.TOUCH_START, this._onDown, this); this.node.off(Node.EventType.TOUCH_MOVE, this._onMove, this); this.node.off(Node.EventType.TOUCH_END, this._onUp, this); this.node.off(Node.EventType.TOUCH_CANCEL, this._onUp, this); if (this._nodePool) { this._nodePool.clear(); this._nodePool = null; } // 销毁模板节点 if (this._templateNode) { this._templateNode.destroy(); this._templateNode = null; } } private _bindTouch() { this.node.on(Node.EventType.TOUCH_START, this._onDown, this); this.node.on(Node.EventType.TOUCH_MOVE, this._onMove, this); this.node.on(Node.EventType.TOUCH_END, this._onUp, this); this.node.on(Node.EventType.TOUCH_CANCEL, this._onUp, this); } private _bindGlobalTouch() { input.on(Input.EventType.TOUCH_END, this._onGlobalTouchEnd, this); input.on(Input.EventType.TOUCH_CANCEL, this._onGlobalTouchEnd, this); } private _onGlobalTouchEnd(event: EventTouch) { if (this._isTouching) { console.log('[VScrollView] Global touch end detected'); this._onUp(event); } } private async _initFixedSizeMode() { if (!this.provideNodeFn) { this.provideNodeFn = (index: number) => { // 优先使用 itemPrefab if (this.itemPrefab) return instantiate(this.itemPrefab); // 其次使用模板节点 if (this._templateNode) return instantiate(this._templateNode); // 都没有则警告并创建默认节点 console.warn('[VirtualScrollView] 没有提供 itemPrefab 或模板节点'); const n = new Node('item-auto-create'); const size = this._isVertical() ? this._viewportTf.width : this._viewportTf.height; n.addComponent(UITransform).setContentSize(this._isVertical() ? size : this.itemMainSize, this._isVertical() ? this.itemMainSize : size); return n; }; } let item_pre = this.provideNodeFn(0); if (item_pre instanceof Promise) item_pre = await item_pre; const uit = item_pre.getComponent(UITransform); if (this._isVertical()) { this.itemMainSize = uit.height; this.itemCrossSize = uit.width; } else { this.itemMainSize = uit.width; this.itemCrossSize = uit.height; } this._recomputeContentSize(); const stride = this.itemMainSize + this.spacing; const visibleLines = Math.ceil(this._viewportSize / stride); this._slots = Math.max(1, (visibleLines + this.buffer + 2) * this.gridCount); for (let i = 0; i < this._slots; i++) { const n = instantiate(item_pre); n.parent = this.content!; const itf = n.getComponent(UITransform); if (itf) { if (this._isVertical()) { itf.width = this.itemCrossSize; itf.height = this.itemMainSize; } else { itf.width = this.itemMainSize; itf.height = this.itemCrossSize; } } this._slotNodes.push(n); } this._slotFirstIndex = 0; this._layoutSlots(this._slotFirstIndex, true); } private async _initDynamicSizeMode() { if (this.getItemHeightFn) { console.log('[VirtualScrollView] 使用外部提供的 getItemHeightFn'); this._itemSizes = []; for (let i = 0; i < this.totalCount; i++) { this._itemSizes.push(this.getItemHeightFn(i)); } this._buildPrefixSum(); if (this.itemPrefabs.length > 0) { console.log('[VirtualScrollView] 初始化节点池'); this._nodePool = new InternalNodePool(this.itemPrefabs); } else { console.error('[VirtualScrollView] 需要至少一个 itemPrefab'); return; } this._initDynamicSlots(); return; } if (this.itemPrefabs.length === 0 || !this.getItemTypeIndexFn) { console.error( '[VirtualScrollView] 不等大小模式必须提供以下之一:\n1. getItemHeightFn 回调函数\n2. itemPrefabs 数组 + getItemTypeIndexFn 回调函数' ); return; } console.log('[VirtualScrollView] 使用采样模式(从 itemPrefabs 采样尺寸)'); this._nodePool = new InternalNodePool(this.itemPrefabs); this._prefabSizeCache.clear(); for (let i = 0; i < this.itemPrefabs.length; i++) { const sampleNode = instantiate(this.itemPrefabs[i]); const uit = sampleNode.getComponent(UITransform); const size = this._isVertical() ? uit?.height || 100 : uit?.width || 100; this._prefabSizeCache.set(i, size); sampleNode.destroy(); console.log(`[VirtualScrollView] 预制体[${i}] 采样尺寸: ${size}`); } this._itemSizes = []; for (let i = 0; i < this.totalCount; i++) { const typeIndex = this.getItemTypeIndexFn(i); const size = this._prefabSizeCache.get(typeIndex); if (size !== undefined) { this._itemSizes.push(size); } else { console.warn(`[VirtualScrollView] 索引 ${i} 的类型索引 ${typeIndex} 无效,使用默认尺寸`); this._itemSizes.push(this._prefabSizeCache.get(0) || 100); } } this._buildPrefixSum(); this._initDynamicSlots(); } private _initDynamicSlots() { const avgSize = this._contentSize / this.totalCount || 100; const visibleCount = Math.ceil(this._viewportSize / avgSize); let neededSlots = visibleCount + this.buffer * 2 + 4; const minSlots = Math.ceil(this._viewportSize / 80) + this.buffer * 2; neededSlots = Math.max(neededSlots, minSlots); const maxSlots = Math.ceil(this._viewportSize / 50) + this.buffer * 4; neededSlots = Math.min(neededSlots, maxSlots); this._slots = Math.min(neededSlots, Math.max(this.totalCount, minSlots)); this._slotNodes = new Array(this._slots).fill(null); this._slotPrefabIndices = new Array(this._slots).fill(-1); this._slotFirstIndex = 0; this._layoutSlots(this._slotFirstIndex, true); console.log(`[VScrollView] 初始化槽位: ${this._slots} (总数据: ${this.totalCount}, 视口尺寸: ${this._viewportSize})`); } private _buildPrefixSum() { const n = this._itemSizes.length; this._prefixPositions = new Array(n); // 从 headerSpacing 开始 let acc = this.headerSpacing; for (let i = 0; i < n; i++) { this._prefixPositions[i] = acc; acc += this._itemSizes[i] + this.spacing; } // 内容总大小 = 最后一个位置 + 最后一项大小 - spacing + footerSpacing this._contentSize = acc - this.spacing + this.footerSpacing; if (this._contentSize < 0) this._contentSize = 0; if (this._isVertical()) this._contentTf.height = Math.max(this._contentSize, this._viewportSize); else this._contentTf.width = Math.max(this._contentSize, this._viewportSize); if (this._isVertical()) { this._boundsMin = 0; this._boundsMax = Math.max(0, this._contentSize - this._viewportSize); } else { this._boundsMin = -Math.max(0, this._contentSize - this._viewportSize); this._boundsMax = 0; } } private _posToFirstIndex(pos: number): number { // _prefixPositions 已经包含了 headerSpacing,直接查找即可 if (pos <= this.headerSpacing) return 0; // 修改:如果在 header 区域内,返回 0 let l = 0, r = this._prefixPositions.length - 1, ans = this._prefixPositions.length; while (l <= r) { const m = (l + r) >> 1; if (this._prefixPositions[m] > pos) { ans = m; r = m - 1; } else { l = m + 1; } } return Math.max(0, ans - 1); } private _calcVisibleRange(scrollPos: number): { start: number; end: number } { const n = this._prefixPositions.length; if (n === 0) return { start: 0, end: 0 }; const start = this._posToFirstIndex(scrollPos); const endPos = scrollPos + this._viewportSize; let end = start; // 找到第一个起始位置超出可视区域的 item while (end < n) { if (this._prefixPositions[end] >= endPos) break; // 恢复原来的逻辑 end++; } return { start: Math.max(0, start - this.buffer), end: Math.min(n, end + this.buffer) }; } update(dt: number) { if (!this.content || this._isTouching || this._scrollTween) return; let pos = this._getContentMainPos(); let a = 0; const minBound = Math.min(this._boundsMin, this._boundsMax); const maxBound = Math.max(this._boundsMin, this._boundsMax); // 处理刷新/加载状态 if (this._isRefreshing && this._refreshState === RefreshState.REFRESHING) { // 刷新中,保持在刷新位置 const refreshPos = this._isVertical() ? -this.pullRefreshThreshold : this.pullRefreshThreshold; a = -this.springK * (pos - refreshPos) - this.springC * this._velocity; } else if (this._isLoadingMore && this._loadMoreState === LoadMoreState.LOADING) { // 加载中,保持在加载位置 const loadPos = this._isVertical() ? this._boundsMax + this.loadMoreThreshold : this._boundsMin - this.loadMoreThreshold; a = -this.springK * (pos - loadPos) - this.springC * this._velocity; } else if (pos < minBound) { a = -this.springK * (pos - minBound) - this.springC * this._velocity; } else if (pos > maxBound) { a = -this.springK * (pos - maxBound) - this.springC * this._velocity; } else { if (this.useIOSDecelerationCurve) { const speed = Math.abs(this._velocity); if (speed > 2000) this._velocity *= Math.exp(-this.inertiaDampK * 0.7 * dt); else if (speed > 500) this._velocity *= Math.exp(-this.inertiaDampK * dt); else this._velocity *= Math.exp(-this.inertiaDampK * 1.3 * dt); } else { this._velocity *= Math.exp(-this.inertiaDampK * dt); } } this._velocity += a * dt; // 分页吸附模式:使用单独的速度阈值 if (this.enablePageSnap && Math.abs(this._velocity) < this.pageSnapTriggerVelocity && a === 0) { this._velocity = 0; this._performPageSnap(); return; } if (Math.abs(this._velocity) < this.velocitySnap && a === 0) this._velocity = 0; if (this._velocity !== 0) { pos += this._velocity * dt; if (this.pixelAlign) pos = Math.round(pos); this._setContentMainPos(pos); if (this.useVirtualList) this._updateVisible(false); } } public updateItemHeight(index: number, newSize?: number) { if (!this.useDynamicSize) { console.warn('[VScrollView] 只有不等大小模式支持 updateItemHeight'); return; } if (index < 0 || index >= this.totalCount) { console.warn(`[VScrollView] 索引 ${index} 超出范围`); return; } let size = newSize; if (size === undefined) { if (this.getItemHeightFn) { size = this.getItemHeightFn(index); } else { console.error('[VScrollView] 没有提供 newSize 参数,且未设置 getItemHeightFn'); return; } } if (this._itemSizes[index] === size) return; this._itemSizes[index] = size; this._rebuildPrefixSumFrom(index); this._updateVisible(true); } private _rebuildPrefixSumFrom(startIndex: number) { if (startIndex === 0) { this._buildPrefixSum(); return; } let acc = this._prefixPositions[startIndex - 1] + this._itemSizes[startIndex - 1] + this.spacing; for (let i = startIndex; i < this._itemSizes.length; i++) { this._prefixPositions[i] = acc; acc += this._itemSizes[i] + this.spacing; } this._contentSize = acc - this.spacing + this.footerSpacing; if (this._contentSize < 0) this._contentSize = 0; if (this._isVertical()) this._contentTf.height = Math.max(this._contentSize, this._viewportSize); else this._contentTf.width = Math.max(this._contentSize, this._viewportSize); if (this._isVertical()) { this._boundsMin = 0; this._boundsMax = Math.max(0, this._contentSize - this._viewportSize); } else { this._boundsMin = -Math.max(0, this._contentSize - this._viewportSize); this._boundsMax = 0; } } public updateItemHeights(updates: Array<{ index: number; height: number }>) { if (!this.useDynamicSize) { console.warn('[VScrollView] 只有不等大小模式支持 updateItemHeights'); return; } if (updates.length === 0) return; let minIndex = this.totalCount; let hasChange = false; for (const { index, height } of updates) { if (index < 0 || index >= this.totalCount) continue; if (this._itemSizes[index] !== height) { this._itemSizes[index] = height; minIndex = Math.min(minIndex, index); hasChange = true; } } if (!hasChange) return; this._rebuildPrefixSumFrom(minIndex); this._updateVisible(true); } public refreshList(data: any[] | number) { if (!this.useVirtualList) { console.warn('[VirtualScrollView] 简单滚动模式不支持 refreshList'); return; } if (typeof data === 'number') this.setTotalCount(data); else this.setTotalCount(data.length); } public setTotalCount(count: number) { this._getContentNode(); if (!this.useVirtualList) { console.warn('[VScrollView] 非虚拟列表模式,不支持 setTotalCount'); return; } this._upWidgetAlignment(); const oldCount = this.totalCount; this.totalCount = Math.max(0, count | 0); if (this.totalCount > oldCount) { for (let i = oldCount; i < this.totalCount; i++) { this._needAnimateIndices.add(i); } } if (this.useDynamicSize) { const oldLength = this._itemSizes.length; if (this.totalCount > oldLength) { for (let i = oldLength; i < this.totalCount; i++) { let size = 100; if (this.getItemHeightFn) { size = this.getItemHeightFn(i); } else if (this.getItemTypeIndexFn && this._prefabSizeCache.size > 0) { const typeIndex = this.getItemTypeIndexFn(i); size = this._prefabSizeCache.get(typeIndex) || 100; } this._itemSizes.push(size); } } else if (this.totalCount < oldLength) { this._itemSizes.length = this.totalCount; } this._buildPrefixSum(); if (this.totalCount > oldCount) this._expandSlotsIfNeeded(); } else { this._recomputeContentSize(); } this._slotFirstIndex = math.clamp(this._slotFirstIndex, 0, Math.max(0, this.totalCount - 1)); if (!this.useDynamicSize) { this._layoutSlots(this._slotFirstIndex, true); } this._updateVisible(true); } _upWidgetAlignment() { this.content?.getComponent?.(Widget)?.updateAlignment?.(); this.node?.getComponent?.(Widget)?.updateAlignment?.(); } private _expandSlotsIfNeeded() { let neededSlots = 0; let pos = 0; const endPos = this._viewportSize; for (let i = 0; i < this.totalCount; i++) { if (pos >= endPos) break; neededSlots++; pos += this._itemSizes[i] + this.spacing; } neededSlots += this.buffer * 2 + 4; const minSlots = Math.ceil(this._viewportSize / 80) + this.buffer * 2; neededSlots = Math.max(neededSlots, minSlots); const maxSlots = Math.ceil(this._viewportSize / 50) + this.buffer * 4; neededSlots = Math.min(neededSlots, maxSlots); if (neededSlots > this._slots) { const oldSlots = this._slots; this._slots = neededSlots; for (let i = oldSlots; i < this._slots; i++) { this._slotNodes.push(null); this._slotPrefabIndices.push(-1); } console.log(`[VScrollView] 槽位扩展: ${oldSlots} -> ${this._slots} (总数据: ${this.totalCount})`); } } private _scrollToPosition(targetPos: number, animate = false, duration?: number) { targetPos = math.clamp(targetPos, this._boundsMin, this._boundsMax); if (this._scrollTween) { this._scrollTween.stop(); this._scrollTween = null; } this._velocity = 0; this._isTouching = false; this._velSamples.length = 0; if (!animate) { this._setContentMainPos(this.pixelAlign ? Math.round(targetPos) : targetPos); this._updateVisible(true); } else { const currentPos = this._getContentMainPos(); const distance = Math.abs(targetPos - currentPos); // 如果提供了 duration 则使用,否则根据距离自动计算 const finalDuration = duration !== undefined ? duration : Math.max(0.2, distance / 3000); const targetVec = this._isVertical() ? new Vec3(0, targetPos, 0) : new Vec3(targetPos, 0, 0); this._scrollTween = tween(this.content!) .to( finalDuration, { position: targetVec }, { easing: 'smooth', onUpdate: () => { this._updateVisible(false); }, } ) .call(() => { this._updateVisible(true); this._scrollTween = null; this._velocity = 0; }) .start(); } } public scrollToTop(animate = false, duration?: number) { const target = this._isVertical() ? this._boundsMin : this._boundsMax; this._scrollToPosition(target, animate, duration); } public scrollToBottom(animate = false, duration?: number) { const target = this._isVertical() ? this._boundsMax : this._boundsMin; this._scrollToPosition(target, animate, duration); } public scrollToIndex(index: number, animate = false, duration?: number) { index = math.clamp(index | 0, 0, Math.max(0, this.totalCount - 1)); let targetPos = 0; if (this.useDynamicSize) { // 不等大小模式:_prefixPositions 已经包含了 headerSpacing targetPos = this._prefixPositions[index] || 0; } else { // 等大小模式:需要手动加上 headerSpacing const line = Math.floor(index / this.gridCount); targetPos = this.headerSpacing + line * (this.itemMainSize + this.spacing); } // 横向模式:滚动方向相反,取负值 if (!this._isVertical()) { targetPos = -targetPos; } this._scrollToPosition(targetPos, animate, duration); } public onOffSortLayer(onoff: boolean) { this._initSortLayerFlag = onoff; this._onOffSortLayerOperation(); } private _onOffSortLayerOperation() { for (const element of this._slotNodes) { const sitem = element?.getComponent(VScrollViewItem); if (sitem) { if (this._initSortLayerFlag) sitem.onSortLayer(); else sitem.offSortLayer(); } } } private _flashToPosition(targetPos: number) { targetPos = math.clamp(targetPos, this._boundsMin, this._boundsMax); if (this._scrollTween) { this._scrollTween.stop(); this._scrollTween = null; } this._velocity = 0; this._isTouching = false; this._velSamples.length = 0; this._setContentMainPos(this.pixelAlign ? Math.round(targetPos) : targetPos); this._updateVisible(true); } public flashToTop() { const target = this._isVertical() ? this._boundsMin : this._boundsMax; this._flashToPosition(target); } public flashToBottom() { const target = this._isVertical() ? this._boundsMax : this._boundsMin; this._flashToPosition(target); } public flashToIndex(index: number) { if (!this.useVirtualList) { console.warn('[VirtualScrollView] 简单滚动模式不支持 flashToIndex'); return; } index = math.clamp(index | 0, 0, Math.max(0, this.totalCount - 1)); let targetPos = 0; if (this.useDynamicSize) { // 不等大小模式:_prefixPositions 已经包含了 headerSpacing targetPos = this._prefixPositions[index] || 0; } else { // 等大小模式:需要手动加上 headerSpacing const line = Math.floor(index / this.gridCount); targetPos = this.headerSpacing + line * (this.itemMainSize + this.spacing); } if (!this._isVertical()) { targetPos = -targetPos; } this._flashToPosition(targetPos); } public refreshIndex(index: number) { if (!this.useVirtualList) { console.warn('[VirtualScrollView] 简单滚动模式不支持 refreshIndex'); return; } const first = this._slotFirstIndex; const last = first + this._slots - 1; if (index < first || index > last) return; const slot = index - first; const node = this._slotNodes[slot]; if (node && this.renderItemFn) this.renderItemFn(node, index); } private _stopTouchEvent(e?: EventTouch) { if (!e) return; // 如果已经确定要阻止父级,直接阻止 if (this._shouldBlockParent) { e.propagationStopped = true; } } private _onDown(e: EventTouch) { // 记录触摸起始位置 const uiPos = e.getUILocation(this._touchStartPos); this._touchStartPos.set(uiPos); this._hasDeterminedScrollDirection = false; this._shouldBlockParent = false; // 分页模式:记录触摸开始时的内容位置 if (this.enablePageSnap) { this._pageStartPos = this._getContentMainPos(); } this._stopTouchEvent(e); this._isTouching = true; this._velocity = 0; this._velSamples.length = 0; if (this._scrollTween) { this._scrollTween.stop(); this._scrollTween = null; } } private _onMove(e: EventTouch) { if (!this._isTouching) return; const uiDelta = e.getUIDelta(this._tmpMoveVec2); const currentPos = e.getUILocation(); // 第一次移动时判断滑动方向 if (!this._hasDeterminedScrollDirection) { const deltaX = currentPos.x - this._touchStartPos.x; const deltaY = currentPos.y - this._touchStartPos.y; const totalDelta = Math.sqrt(deltaX * deltaX + deltaY * deltaY); // 超过距离阈值才判断方向 if (totalDelta > this._scrollDirectionThreshold) { this._hasDeterminedScrollDirection = true; // 计算滑动角度(相对于水平方向) const angle = Math.abs((Math.atan2(deltaY, deltaX) * 180) / Math.PI); // 判断是否为纵向滑动:角度在 [90° - 阈值, 90° + 阈值] 范围内 const isVerticalScroll = angle > 90 - this._scrollAngleThreshold && angle < 90 + this._scrollAngleThreshold; // 判断是否为横向滑动:角度在 [0°, 阈值] 或 [180° - 阈值, 180°] 范围内 const isHorizontalScroll = angle < this._scrollAngleThreshold || angle > 180 - this._scrollAngleThreshold; const isListVertical = this._isVertical(); // 方向一致时才考虑拦截 if ((isListVertical && isVerticalScroll) || (!isListVertical && isHorizontalScroll)) { // 检查是否在边界 const pos = this._getContentMainPos(); const minBound = Math.min(this._boundsMin, this._boundsMax); const maxBound = Math.max(this._boundsMin, this._boundsMax); const delta = this._isVertical() ? uiDelta.y : uiDelta.x; // 判断滑动方向 const scrollingToStart = this._isVertical() ? delta > 0 : delta < 0; const scrollingToEnd = this._isVertical() ? delta < 0 : delta > 0; // 只有在非边界位置,或者在边界但向内滑动时才拦截 const atStartBound = this._isVertical() ? pos <= minBound : pos >= maxBound; const atEndBound = this._isVertical() ? pos >= maxBound : pos <= minBound; if ((!atStartBound && !atEndBound) || (atStartBound && scrollingToEnd) || (atEndBound && scrollingToStart)) { this._shouldBlockParent = true; } } } } this._stopTouchEvent(e); // const uiDelta = e.getUIDelta(this._tmpMoveVec2); const delta = this._isVertical() ? uiDelta.y : uiDelta.x; let pos = this._getContentMainPos(); const minBound = Math.min(this._boundsMin, this._boundsMax); const maxBound = Math.max(this._boundsMin, this._boundsMax); // 计算是否需要下拉刷新或上拉加载 let finalDelta = delta; let isPullingRefresh = false; let isPullingLoadMore = false; // console.log(`delta: ${delta}, pos: ${pos}, minBound: ${minBound}, maxBound: ${maxBound}`); if (this.enablePullRefresh && !this._isRefreshing) { // 纵向:顶部下拉(pos < minBound 且向下拉) // 横向:左侧右拉(pos > maxBound 且向右拉) const atTopBound = this._isVertical() ? pos <= minBound : pos >= maxBound; const pullingDown = this._isVertical() ? delta < 0 : delta > 0; if (atTopBound && pullingDown) { isPullingRefresh = true; const overOffset = this._isVertical() ? minBound - pos : pos - maxBound; const resistance = 1 - Math.min(overOffset / this.pullRefreshMaxOffset, 1) * (1 - this.pullDampingRate); finalDelta = delta * resistance; this._pullOffset = Math.min(overOffset + Math.abs(finalDelta), this.pullRefreshMaxOffset); // console.log(`[VScrollView] 下拉偏移: ${this._pullOffset}`); // 更新刷新状态 if (this._pullOffset >= this.pullRefreshThreshold) { this._updateRefreshState(RefreshState.READY, this._pullOffset); } else { this._updateRefreshState(RefreshState.PULLING, this._pullOffset); } } } if (this.enableLoadMore && !this._isLoadingMore && this._hasMore) { // 纵向:底部上拉(pos > maxBound 且向上拉) // 横向:右侧左拉(pos < minBound 且向左拉) const atBottomBound = this._isVertical() ? pos >= maxBound : pos <= minBound; const pullingUp = this._isVertical() ? delta > 0 : delta < 0; if (atBottomBound && pullingUp) { isPullingLoadMore = true; const overOffset = this._isVertical() ? pos - maxBound : minBound - pos; const resistance = 1 - Math.min(overOffset / this.loadMoreMaxOffset, 1) * (1 - this.pullDampingRate); finalDelta = delta * resistance; this._loadOffset = Math.min(overOffset + Math.abs(finalDelta), this.loadMoreMaxOffset); // console.log(`[VScrollView] 上拉偏移: ${this._loadOffset}`); // 更新加载状态 if (this._loadOffset >= this.loadMoreThreshold) { this._updateLoadMoreState(LoadMoreState.READY, this._loadOffset); } else { this._updateLoadMoreState(LoadMoreState.PULLING, this._loadOffset); } } } // 应用位置变化 pos += finalDelta; if (this.pixelAlign) pos = Math.round(pos); this._setContentMainPos(pos); // 记录速度采样 const t = performance.now() / 1000; this._velSamples.push({ t, delta: finalDelta }); const t0 = t - this.velocityWindow; while (this._velSamples.length && this._velSamples[0].t < t0) this._velSamples.shift(); if (this.useVirtualList) this._updateVisible(false); } private _onUp(e?: EventTouch) { // 重置方向判断标志 this._hasDeterminedScrollDirection = false; this._shouldBlockParent = false; this._stopTouchEvent(e); if (!this._isTouching) return; this._isTouching = false; // 检查是否触发刷新 if (this._refreshState === RefreshState.READY && !this._isRefreshing) { this._triggerRefresh(); this._velSamples.length = 0; return; } // 检查是否触发加载 if (this._loadMoreState === LoadMoreState.READY && !this._isLoadingMore) { this._triggerLoadMore(); this._velSamples.length = 0; return; } // 重置状态 if (this._refreshState !== RefreshState.REFRESHING) { this._pullOffset = 0; this._updateRefreshState(RefreshState.IDLE, 0); } if (this._loadMoreState !== LoadMoreState.LOADING) { this._loadOffset = 0; this._updateLoadMoreState(LoadMoreState.IDLE, 0); } // 计算速度 if (this._velSamples.length >= 2) { let sum = 0; let dtSum = 0; const sampleCount = Math.min(this._velSamples.length, 5); const startIndex = this._velSamples.length - sampleCount; for (let i = startIndex + 1; i < this._velSamples.length; i++) { sum += this._velSamples[i].delta; dtSum += this._velSamples[i].t - this._velSamples[i - 1].t; } if (dtSum > 0.001) { this._velocity = sum / dtSum; this._velocity = math.clamp(this._velocity, -this.maxVelocity, this.maxVelocity); } else { this._velocity = this._velSamples.length > 0 ? math.clamp(this._velSamples[this._velSamples.length - 1].delta * 60, -this.maxVelocity, this.maxVelocity) : 0; } } else if (this._velSamples.length === 1) { this._velocity = math.clamp(this._velSamples[0].delta * 60, -this.maxVelocity, this.maxVelocity); } else { this._velocity = 0; } this._velSamples.length = 0; // 分页吸附模式:根据滑动距离判断翻页 if (this.enablePageSnap) { this._performPageSnapByDistance(); } } // 更新刷新状态 private _updateRefreshState(state: RefreshState, offset: number) { if (this._refreshState === state) return; this._refreshState = state; if (this.onRefreshStateChangeFn) { this.onRefreshStateChangeFn(state, offset); } } // 更新加载状态 private _updateLoadMoreState(state: LoadMoreState, offset: number) { if (this._loadMoreState === state) return; this._loadMoreState = state; if (this.onLoadMoreStateChangeFn) { this.onLoadMoreStateChangeFn(state, offset); } } // 触发刷新 private _triggerRefresh() { this._isRefreshing = true; this._velocity = 0; this._updateRefreshState(RefreshState.REFRESHING, this.pullRefreshThreshold); } // 触发加载更多 private _triggerLoadMore() { this._isLoadingMore = true; this._velocity = 0; this._updateLoadMoreState(LoadMoreState.LOADING, this.loadMoreThreshold); } /** * 完成刷新(外部调用) * @param success 是否刷新成功 */ public finishRefresh(success: boolean = true) { if (!this._isRefreshing) return; this._isRefreshing = false; this._pullOffset = 0; this._updateRefreshState(success ? RefreshState.COMPLETE : RefreshState.IDLE, 0); // 延迟重置到 IDLE 状态 this.scheduleOnce(() => { if (this._refreshState === RefreshState.COMPLETE) { this._updateRefreshState(RefreshState.IDLE, 0); } }, 0.3); } /** * 完成加载更多(外部调用) * @param hasMore 是否还有更多数据 */ public finishLoadMore(hasMore: boolean = true) { if (!this._isLoadingMore) return; this._isLoadingMore = false; this._loadOffset = 0; this._hasMore = hasMore; if (!hasMore) { this._updateLoadMoreState(LoadMoreState.NO_MORE, 0); } else { this._updateLoadMoreState(LoadMoreState.COMPLETE, 0); // 延迟重置到 IDLE 状态 this.scheduleOnce(() => { if (this._loadMoreState === LoadMoreState.COMPLETE) { this._updateLoadMoreState(LoadMoreState.IDLE, 0); } }, 0.3); } } /** * 重置加载更多状态(当数据清空或重新加载时调用) */ public resetLoadMoreState() { this._hasMore = true; this._isLoadingMore = false; this._loadOffset = 0; this._updateLoadMoreState(LoadMoreState.IDLE, 0); } private _updateVisible(force: boolean) { if (!this.useVirtualList) return; let scrollPos = this._getContentMainPos(); let searchPos: number; if (this._isVertical()) { searchPos = math.clamp(scrollPos, 0, this._contentSize); } else { searchPos = math.clamp(-scrollPos, 0, this._contentSize); } let newFirst = 0; if (this.useDynamicSize) { const range = this._calcVisibleRange(searchPos); newFirst = range.start; } else { const stride = this.itemMainSize + this.spacing; // 减去 headerSpacing 后再计算行号 const adjustedPos = Math.max(0, searchPos - this.headerSpacing); const firstLine = Math.floor(adjustedPos / stride); const first = firstLine * this.gridCount; newFirst = math.clamp(first, 0, Math.max(0, this.totalCount - 1)); } if (this.totalCount < this._slots) newFirst = 0; if (force) { this._slotFirstIndex = newFirst; this._layoutSlots(this._slotFirstIndex, true); return; } const diff = newFirst - this._slotFirstIndex; if (diff === 0) return; if (Math.abs(diff) >= this._slots) { this._slotFirstIndex = newFirst; this._layoutSlots(this._slotFirstIndex, true); return; } const absDiff = Math.abs(diff); if (diff > 0) { const moved = this._slotNodes.splice(0, absDiff); this._slotNodes.push(...moved); if (this.useDynamicSize && this._slotPrefabIndices.length > 0) { const movedIndices = this._slotPrefabIndices.splice(0, absDiff); this._slotPrefabIndices.push(...movedIndices); } this._slotFirstIndex = newFirst; for (let i = 0; i < absDiff; i++) { const slot = this._slots - absDiff + i; const idx = this._slotFirstIndex + slot; if (idx >= this.totalCount) { const node = this._slotNodes[slot]; if (node) node.active = false; } else { this._layoutSingleSlot(this._slotNodes[slot], idx, slot); } } } else { const moved = this._slotNodes.splice(this._slotNodes.length + diff, absDiff); this._slotNodes.unshift(...moved); if (this.useDynamicSize && this._slotPrefabIndices.length > 0) { const movedIndices = this._slotPrefabIndices.splice(this._slotPrefabIndices.length + diff, absDiff); this._slotPrefabIndices.unshift(...movedIndices); } this._slotFirstIndex = newFirst; for (let i = 0; i < absDiff; i++) { const idx = this._slotFirstIndex + i; if (idx >= this.totalCount) { const node = this._slotNodes[i]; if (node) node.active = false; } else { this._layoutSingleSlot(this._slotNodes[i], idx, i); } } } } private async _layoutSingleSlot(node: Node | null, idx: number, slot: number) { if (!this.useVirtualList) return; if (this.useDynamicSize) { let targetPrefabIndex = this.getItemTypeIndexFn(idx); const currentPrefabIndex = this._slotPrefabIndices[slot]; let newNode: Node | null = null; if (currentPrefabIndex === targetPrefabIndex && this._slotNodes[slot]) { newNode = this._slotNodes[slot]; } else { if (this._slotNodes[slot] && this._nodePool && currentPrefabIndex >= 0) { this._nodePool.put(this._slotNodes[slot], currentPrefabIndex); } if (this._nodePool) { newNode = this._nodePool.get(targetPrefabIndex); if (!newNode) { console.error(`[VScrollView] 无法获取类型 ${targetPrefabIndex} 的节点`); return; } newNode.parent = this.content; this._slotNodes[slot] = newNode; this._slotPrefabIndices[slot] = targetPrefabIndex; } } if (!newNode) { console.error(`[VScrollView] 槽位 ${slot} 节点为空,索引 ${idx}`); return; } newNode.active = true; this._updateItemClickHandler(newNode, idx); if (this.renderItemFn) this.renderItemFn(newNode, idx); if (this.getItemHeightFn) { const expectedSize = this.getItemHeightFn(idx); if (this._itemSizes[idx] !== expectedSize) { this.updateItemHeight(idx, expectedSize); return; } } else { const uit = newNode.getComponent(UITransform); const actualSize = this._isVertical() ? uit?.height || 100 : uit?.width || 100; if (Math.abs(this._itemSizes[idx] - actualSize) > 1) { this.updateItemHeight(idx, actualSize); return; } } const uit = newNode.getComponent(UITransform); const size = this._itemSizes[idx]; const itemStart = this._prefixPositions[idx]; if (this._isVertical()) { const anchorY = uit?.anchorY ?? 0.5; const anchorOffsetY = size * (1 - anchorY); const nodeY = itemStart + anchorOffsetY; const y = -nodeY; newNode.setPosition(0, this.pixelAlign ? Math.round(y) : y); } else { // 修改:横向模式下,itemStart 是正值,但 content.x 是负值 // 所以 item 的 x 位置应该直接使用 itemStart(因为 content 整体向左移动) const anchorX = uit?.anchorX ?? 0.5; const anchorOffsetX = size * anchorX; const nodeX = itemStart + anchorOffsetX; // 不需要取负,因为 content 本身已经是负值了 const x = nodeX; newNode.setPosition(this.pixelAlign ? Math.round(x) : x, 0); } if (this._needAnimateIndices.has(idx)) { if (this.playItemAppearAnimationFn) this.playItemAppearAnimationFn(newNode, idx); else this._playDefaultItemAppearAnimation(newNode, idx); this._needAnimateIndices.delete(idx); } } else { // 等大小模式 if (!node) return; node.active = true; const stride = this.itemMainSize + this.spacing; const line = Math.floor(idx / this.gridCount); const gridPos = idx % this.gridCount; const uit = node.getComponent(UITransform); // 1. 计算基础位置(包含 headerSpacing) const itemStart = this.headerSpacing + line * stride; // 2. 计算全局偏移(视口居中)- 只在内容小于视口时生效 let globalOffset = 0; let shouldAutoCenter = false; // 是否应该居中 if (this.autoCenter) { const totalLines = Math.ceil(this.totalCount / this.gridCount); const totalContentSize = this.headerSpacing + totalLines * stride - this.spacing + this.footerSpacing; // 只有当内容小于视口时才居中 if (totalContentSize < this._viewportSize) { shouldAutoCenter = true; globalOffset = (this._viewportSize - totalContentSize) / 2; } } if (this._isVertical()) { // 纵向模式:主方向是 Y,副方向是 X const anchorY = uit?.anchorY ?? 0.5; const anchorOffsetY = this.itemMainSize * (1 - anchorY); const nodeY = itemStart + anchorOffsetY + globalOffset; const y = -nodeY; // 3. 计算当前行的实际子项数量(行内居中)- 只在启用居中且内容小于视口时生效 let actualCountInLine = this.gridCount; if (shouldAutoCenter) { const startIdxOfLine = line * this.gridCount; const endIdxOfLine = Math.min(startIdxOfLine + this.gridCount, this.totalCount); actualCountInLine = endIdxOfLine - startIdxOfLine; } // 根据实际数量计算总宽度和位置 const totalWidth = actualCountInLine * this.itemCrossSize + (actualCountInLine - 1) * this.gridSpacing; const x = gridPos * (this.itemCrossSize + this.gridSpacing) - totalWidth / 2 + this.itemCrossSize / 2; node.setPosition(this.pixelAlign ? Math.round(x) : x, this.pixelAlign ? Math.round(y) : y); if (uit) { uit.width = this.itemCrossSize; uit.height = this.itemMainSize; } } else { // 横向模式:主方向是 X,副方向是 Y const anchorX = uit?.anchorX ?? 0.5; const anchorOffsetX = this.itemMainSize * anchorX; const nodeX = itemStart + anchorOffsetX + globalOffset; const x = nodeX; // 3. 计算当前列的实际子项数量(列内居中)- 只在启用居中且内容小于视口时生效 let actualCountInLine = this.gridCount; if (shouldAutoCenter) { const startIdxOfLine = line * this.gridCount; const endIdxOfLine = Math.min(startIdxOfLine + this.gridCount, this.totalCount); actualCountInLine = endIdxOfLine - startIdxOfLine; } // 根据实际数量计算总高度和位置 const totalHeight = actualCountInLine * this.itemCrossSize + (actualCountInLine - 1) * this.gridSpacing; const y = totalHeight / 2 - gridPos * (this.itemCrossSize + this.gridSpacing) - this.itemCrossSize / 2; node.setPosition(this.pixelAlign ? Math.round(x) : x, this.pixelAlign ? Math.round(y) : y); if (uit) { uit.width = this.itemMainSize; uit.height = this.itemCrossSize; } } this._updateItemClickHandler(node, idx); if (this.renderItemFn) this.renderItemFn(node, idx); if (this._needAnimateIndices.has(idx)) { if (this.playItemAppearAnimationFn) this.playItemAppearAnimationFn(node, idx); else this._playDefaultItemAppearAnimation(node, idx); this._needAnimateIndices.delete(idx); } } } private _playDefaultItemAppearAnimation(node: Node, index: number) { } private _updateItemClickHandler(node: Node, index: number) { if (!this.useVirtualList) return; let itemScript = node.getComponent(VScrollViewItem); if (!itemScript) itemScript = node.addComponent(VScrollViewItem); this._initSortLayerFlag ? itemScript.onSortLayer() : itemScript.offSortLayer(); itemScript.useItemClickEffect = this.onItemClickFn ? true : false; if (!itemScript.onClickCallback) { itemScript.onClickCallback = (idx: number) => { if (this.onItemClickFn) this.onItemClickFn(node, idx); }; } if (!itemScript.onLongPressCallback) { itemScript.onLongPressCallback = (idx: number) => { if (this.onItemLongPressFn) this.onItemLongPressFn(node, idx); }; } itemScript.setDataIndex(index); } private _layoutSlots(firstIndex: number, forceRender: boolean) { if (!this.useVirtualList) return; for (let s = 0; s < this._slots; s++) { const idx = firstIndex + s; const node = this._slotNodes[s]; if (idx >= this.totalCount) { if (node) node.active = false; } else { this._layoutSingleSlot(node, idx, s); } } } private _recomputeContentSize() { if (!this.useVirtualList) { this._contentSize = this._isVertical() ? this._contentTf.height : this._contentTf.width; if (this._isVertical()) { this._boundsMin = 0; this._boundsMax = Math.max(0, this._contentSize - this._viewportSize); } else { this._boundsMin = -Math.max(0, this._contentSize - this._viewportSize); this._boundsMax = 0; } return; } if (this.useDynamicSize) return; const stride = this.itemMainSize + this.spacing; const totalLines = Math.ceil(this.totalCount / this.gridCount); // 添加 headerSpacing 和 footerSpacing this._contentSize = totalLines > 0 ? this.headerSpacing + totalLines * stride - this.spacing + this.footerSpacing : 0; if (this._isVertical()) this._contentTf.height = Math.max(this._contentSize, this._viewportSize); else this._contentTf.width = Math.max(this._contentSize, this._viewportSize); if (this._isVertical()) { this._boundsMin = 0; this._boundsMax = Math.max(0, this._contentSize - this._viewportSize); } else { this._boundsMin = -Math.max(0, this._contentSize - this._viewportSize); this._boundsMax = 0; } } /** * 获取当前页索引 */ public getCurrentPageIndex(): number { return this._currentPageIndex; } /** * 滚动到指定页 */ public scrollToPage(pageIndex: number, animate: boolean = true) { if (!this.enablePageSnap) { console.warn('[VScrollView] 未启用分页吸附模式'); return; } const maxPage = this._getMaxPageIndex(); pageIndex = math.clamp(pageIndex, 0, maxPage); const targetPos = this._getPagePosition(pageIndex); this._scrollToPosition(targetPos, animate, this.pageSnapDuration); this._updateCurrentPage(pageIndex); } /** * 获取最大页索引 */ private _getMaxPageIndex(): number { if (this.useDynamicSize) { return Math.max(0, this.totalCount - 1); } else { const totalLines = Math.ceil(this.totalCount / this.gridCount); return Math.max(0, totalLines - 1); } } /** * 根据当前位置计算最近的页索引 */ private _getNearestPageIndex(): number { const pos = this._getContentMainPos(); const searchPos = this._isVertical() ? pos : -pos; if (this.useDynamicSize) { // 不等大小模式:根据 item 的中心位置判断 let nearestIdx = 0; let minDist = Infinity; for (let i = 0; i < this.totalCount; i++) { const itemStart = this._prefixPositions[i]; const itemSize = this._itemSizes[i]; const itemCenter = itemStart + itemSize / 2; const dist = Math.abs(searchPos - itemStart); if (dist < minDist) { minDist = dist; nearestIdx = i; } } return nearestIdx; } else { // 等大小模式:根据行/列计算 const stride = this.itemMainSize + this.spacing; const adjustedPos = Math.max(0, searchPos - this.headerSpacing); const line = Math.round(adjustedPos / stride); return math.clamp(line, 0, this._getMaxPageIndex()); } } /** * 根据页索引计算目标位置 */ private _getPagePosition(pageIndex: number): number { let targetPos = 0; if (this.useDynamicSize) { targetPos = this._prefixPositions[pageIndex] || 0; } else { targetPos = this.headerSpacing + pageIndex * (this.itemMainSize + this.spacing); } // 横向模式取负值 if (!this._isVertical()) { targetPos = -targetPos; } // 限制在边界范围内 return math.clamp(targetPos, this._boundsMin, this._boundsMax); } /** * 更新当前页并触发回调 */ private _updateCurrentPage(pageIndex: number) { if (this._currentPageIndex !== pageIndex) { this._currentPageIndex = pageIndex; if (this.onPageChangeFn) { this.onPageChangeFn(pageIndex); } } } /** * 执行分页吸附 */ private _performPageSnap() { if (!this.enablePageSnap) return; // 如果正在 tween 吸附中,不重复执行 if (this._scrollTween) return; const nearestPage = this._getNearestPageIndex(); const targetPage = math.clamp(nearestPage, 0, this._getMaxPageIndex()); const targetPos = this._getPagePosition(targetPage); const currentPos = this._getContentMainPos(); // 如果已经在目标位置,只更新页码 if (Math.abs(targetPos - currentPos) < 1) { this._updateCurrentPage(targetPage); return; } this._velocity = 0; this._scrollToPosition(targetPos, true, this.pageSnapDuration); this._updateCurrentPage(targetPage); } /** * 根据滑动距离执行分页吸附 */ private _performPageSnapByDistance() { if (!this.enablePageSnap) return; if (this._scrollTween) return; const currentPos = this._getContentMainPos(); const dragDistance = currentPos - this._pageStartPos; // 滑动距离 // 获取当前页的尺寸 const pageSize = this._getCurrentPageSize(); // 判断翻页的距离阈值 const threshold = pageSize * this.pageSnapDistanceRatio; // 基于当前页索引计算目标页 let targetPage = this._currentPageIndex; const maxPage = this._getMaxPageIndex(); if (this._isVertical()) { // 纵向:dragDistance > 0 表示向下滑(看上一页),< 0 表示向上滑(看下一页) if (dragDistance > threshold) { targetPage = this._currentPageIndex + 1; } else if (dragDistance < -threshold) { targetPage = this._currentPageIndex - 1; } } else { // 横向:dragDistance < 0 表示向左滑(看下一页),> 0 表示向右滑(看上一页) if (dragDistance < -threshold) { targetPage = this._currentPageIndex + 1; } else if (dragDistance > threshold) { targetPage = this._currentPageIndex - 1; } } // 限制范围 targetPage = math.clamp(targetPage, 0, maxPage); const targetPos = this._getPagePosition(targetPage); // 如果已经在目标位置,只更新页码 if (Math.abs(targetPos - currentPos) < 1) { this._updateCurrentPage(targetPage); this._velocity = 0; return; } this._velocity = 0; this._scrollToPosition(targetPos, true, this.pageSnapDuration); this._updateCurrentPage(targetPage); } /** * 获取当前页的尺寸 */ private _getCurrentPageSize(): number { if (this.useDynamicSize) { const pageIndex = math.clamp(this._currentPageIndex, 0, this.totalCount - 1); return this._itemSizes[pageIndex] || 100; } else { return this.itemMainSize + this.spacing; } } /** * 根据位置计算页索引 */ private _getPageIndexByPosition(pos: number): number { const searchPos = this._isVertical() ? pos : -pos; if (this.useDynamicSize) { return this._posToFirstIndex(searchPos); } else { const stride = this.itemMainSize + this.spacing; const adjustedPos = Math.max(0, searchPos - this.headerSpacing); const line = Math.floor(adjustedPos / stride); return math.clamp(line, 0, this._getMaxPageIndex()); } } }