All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 47s
1888 lines
64 KiB
TypeScript
1888 lines
64 KiB
TypeScript
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(建议 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<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());
|
||
}
|
||
}
|
||
}
|