rp_10012/assets/Game/SlotRanking/scripts/VScrollView.ts
TJH 354766ccb9
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 47s
龙虎榜相关
2025-12-24 16:55:31 +08:00

1888 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<number, Node[]> = 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<Node>;
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建议 120240' })
public springK: number = 150.0;
@property({ displayName: '弹簧阻尼', tooltip: '越界阻尼 C建议 2232' })
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<number, number> = new Map();
private _nodePool: InternalNodePool | null = null;
private _slotPrefabIndices: number[] = [];
private _needAnimateIndices: Set<number> = 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());
}
}
}