import { _decorator, CCFloat, Component, EventTouch, instantiate, Layout, Node, Tween, tween, UITransform, Vec3, warn, } from 'cc'; const { ccclass, property } = _decorator; /** * 横向循环 Banner。 * * `init` 返回所有实例化出的 Banner 节点,调用方可按下标为节点填充数据。 */ @ccclass('UISwiper') export class UISwiper extends Component { @property(Node) itemPrefab: Node = null; @property(Node) itemContent: Node = null; @property(Node) pointNode: Node = null; @property(Node) pointContent: Node = null; @property(Node) touchNode: Node = null; /** 相邻 itemPrefab 边缘之间的水平间隔。 */ @property(CCFloat) spaceX = 0; /** 松手后切换动画的时长(秒)。 */ @property(CCFloat) elasticDuration = 0.25; /** 自动切换间隔(秒);小于等于 0 时关闭自动播放。 */ @property(CCFloat) autoPlay = 0; private readonly items: Node[] = []; private readonly points: Node[] = []; private current = 0; private pageSpaceX = 0; private firstItemX = 0; private contentOrigin = new Vec3(); private contentOriginRecorded = false; private eventNode: Node = null; private touchStartX = 0; private touchStartY = 0; private contentStartX = 0; private lastOffsetX = 0; private horizontalGesture: boolean | null = null; private touching = false; private animating = false; /** 当前正在展示的 Banner 下标。 */ get currentIndex(): number { return this.current; } /** 当前实例化出的 Banner 节点。 */ get itemNodes(): readonly Node[] { return this.items; } protected onEnable(): void { this.bindTouchEvents(); this.restartAutoPlay(); } protected onDisable(): void { this.unbindTouchEvents(); this.stopAutoPlay(); this.stopAnimation(); } /** * 创建指定数量的 Banner 和指示点,并回到第一页。 * 重复调用时只会清理本组件上一次生成的节点。 */ init(count: number): Node[] { this.stopAutoPlay(); this.stopAnimation(); this.clearGeneratedNodes(); if (!this.itemPrefab || !this.itemContent) { warn('[UISwiper] itemPrefab 和 itemContent 不能为空'); return this.items; } count = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0; this.removeItemLayout(); this.recordContentOrigin(); this.current = 0; for (let i = 0; i < count; i++) { const item = instantiate(this.itemPrefab); item.active = true; this.itemContent.addChild(item); item.setPosition(Vec3.ZERO); this.items.push(item); if (this.pointNode && this.pointContent) { const point = instantiate(this.pointNode); point.active = true; this.pointContent.addChild(point); point.setPosition(Vec3.ZERO); this.points.push(point); } } this.updateItemPositions(); this.pointContent?.getComponent(Layout)?.updateLayout(); this.updatePoints(); this.setContentX(this.contentOrigin.x); this.restartAutoPlay(); return this.items; } private bindTouchEvents(): void { const target = this.touchNode || this.node; if (this.eventNode === target) return; this.unbindTouchEvents(); this.eventNode = target; target.on(Node.EventType.TOUCH_START, this.onTouchStart, this); target.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this); target.on(Node.EventType.TOUCH_END, this.onTouchEnd, this); target.on(Node.EventType.TOUCH_CANCEL, this.onTouchCancel, this); } private unbindTouchEvents(): void { if (!this.eventNode) return; this.eventNode.off(Node.EventType.TOUCH_START, this.onTouchStart, this); this.eventNode.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this); this.eventNode.off(Node.EventType.TOUCH_END, this.onTouchEnd, this); this.eventNode.off(Node.EventType.TOUCH_CANCEL, this.onTouchCancel, this); this.eventNode = null; } private onTouchStart(event: EventTouch): void { this.touching = false; if (this.items.length <= 1 || this.animating) return; this.stopAutoPlay(); const location = event.getUILocation(); this.touching = true; this.touchStartX = location.x; this.touchStartY = location.y; this.contentStartX = this.itemContent.position.x; this.lastOffsetX = 0; this.horizontalGesture = null; this.restoreItemPositions(); } private onTouchMove(event: EventTouch): void { if (!this.touching || this.items.length <= 1 || this.animating) return; const location = event.getUILocation(); let offsetX = location.x - this.touchStartX; const offsetY = location.y - this.touchStartY; if (this.horizontalGesture === null) { if (Math.abs(offsetX) < 5 && Math.abs(offsetY) < 5) return; this.horizontalGesture = Math.abs(offsetX) > Math.abs(offsetY); } if (!this.horizontalGesture) return; // 一次手势最多切换一页,避免快速拖动时越过相邻 Banner。 offsetX = Math.max(-this.pageSpaceX, Math.min(this.pageSpaceX, offsetX)); this.lastOffsetX = offsetX; this.prepareLoopItems(offsetX); this.setContentX(this.contentStartX + offsetX); } private onTouchEnd(event: EventTouch): void { if (!this.touching || this.items.length <= 1 || this.animating) return; this.touching = false; const offsetX = event.getUILocation().x - this.touchStartX; this.settleSwipe(offsetX); } private onTouchCancel(): void { if (!this.touching || this.items.length <= 1 || this.animating) return; this.touching = false; // 手指移出 touchNode 后通常收到 TOUCH_CANCEL,使用最后一次有效位移正常结算。 this.settleSwipe(this.lastOffsetX); } private settleSwipe(offsetX: number): void { if (!this.horizontalGesture) { this.finishSwipe(0); return; } offsetX = Math.max(-this.pageSpaceX, Math.min(this.pageSpaceX, offsetX)); const threshold = Math.min(this.pageSpaceX * 0.25, 80); if (Math.abs(offsetX) < threshold) { this.finishSwipe(0); return; } this.prepareLoopItems(offsetX); this.finishSwipe(offsetX < 0 ? 1 : -1); } /** direction: 1 下一页,-1 上一页,0 回弹。 */ private finishSwipe(direction: -1 | 0 | 1): void { const targetX = this.contentStartX - direction * this.pageSpaceX; const complete = () => { if (direction !== 0) { this.current = (this.current + direction + this.items.length) % this.items.length; } this.restoreItemPositions(); this.setContentX(this.contentOrigin.x - this.current * this.pageSpaceX); this.updatePoints(); this.horizontalGesture = null; this.animating = false; this.restartAutoPlay(); }; // 即使 elasticDuration 被配置为 0,也保留一个可见的回弹/切换过程。 const duration = Math.max(0.1, this.elasticDuration); this.stopAutoPlay(); this.animating = true; tween(this.itemContent) .to(duration, { position: new Vec3(targetX, this.itemContent.position.y, this.itemContent.position.z) }, { easing: 'quadOut' }) .call(complete) .start(); } private onAutoPlay(): void { if (this.items.length <= 1 || this.touching || this.animating) { this.restartAutoPlay(); return; } this.contentStartX = this.itemContent.position.x; this.prepareLoopItems(-this.pageSpaceX); this.finishSwipe(1); } private restartAutoPlay(): void { this.stopAutoPlay(); if ( !Number.isFinite(this.autoPlay) || this.autoPlay <= 0 || this.items.length <= 1 || !this.enabledInHierarchy ) { return; } this.scheduleOnce(this.onAutoPlay, this.autoPlay); } private stopAutoPlay(): void { this.unschedule(this.onAutoPlay); } private updateItemPositions(): void { if (this.items.length === 0) { this.pageSpaceX = 0; this.firstItemX = 0; return; } this.firstItemX = 0; this.pageSpaceX = this.resolvePageSpaceX(); this.restoreItemPositions(); } private resolvePageSpaceX(): number { const touchWidth = (this.touchNode || this.node).getComponent(UITransform)?.width || 0; const itemWidth = this.itemPrefab.getComponent(UITransform)?.width || 0; const pageWidth = itemWidth > 0 ? itemWidth : touchWidth; return Math.max(1, pageWidth + this.spaceX); } private prepareLoopItems(offsetX: number): void { this.restoreItemPositions(); const lastIndex = this.items.length - 1; if (this.current === 0 && offsetX > 0) { this.setItemX(lastIndex, this.firstItemX - this.pageSpaceX); } if (this.current === lastIndex && offsetX < 0) { this.setItemX(0, this.firstItemX + this.items.length * this.pageSpaceX); } } private restoreItemPositions(): void { for (let i = 0; i < this.items.length; i++) { this.setItemX(i, this.firstItemX + i * this.pageSpaceX); } } private setItemX(index: number, x: number): void { const item = this.items[index]; item.setPosition(x, item.position.y, item.position.z); } private updatePoints(): void { for (let i = 0; i < this.points.length; i++) { const activeNode = this.points[i].getChildByName('sptActive'); if (activeNode) activeNode.active = i === this.current; } } private recordContentOrigin(): void { if (this.contentOriginRecorded) return; this.contentOrigin.set(this.itemContent.position); this.contentOrigin.x = this.resolveTouchCenterX(); this.contentOriginRecorded = true; } private resolveTouchCenterX(): number { const touchTransform = (this.touchNode || this.node).getComponent(UITransform); const parentTransform = this.itemContent.parent?.getComponent(UITransform); if (!touchTransform || !parentTransform) return this.itemContent.position.x; const anchor = touchTransform.anchorPoint; const centerInTouch = new Vec3( (0.5 - anchor.x) * touchTransform.width, (0.5 - anchor.y) * touchTransform.height, 0, ); const worldCenter = touchTransform.convertToWorldSpaceAR(centerInTouch); return parentTransform.convertToNodeSpaceAR(worldCenter).x; } private removeItemLayout(): void { const layout = this.itemContent.getComponent(Layout); if (!layout) return; layout.enabled = false; layout.destroy(); } private setContentX(x: number): void { this.itemContent.setPosition(x, this.itemContent.position.y, this.itemContent.position.z); } private stopAnimation(): void { if (this.itemContent) Tween.stopAllByTarget(this.itemContent); this.animating = false; this.touching = false; this.horizontalGesture = null; if (this.contentOriginRecorded && this.itemContent && this.items.length > 0) { this.restoreItemPositions(); this.setContentX(this.contentOrigin.x - this.current * this.pageSpaceX); } } private clearGeneratedNodes(): void { for (const item of this.items) { item.removeFromParent(); item.destroy(); } for (const point of this.points) { point.removeFromParent(); point.destroy(); } this.items.length = 0; this.points.length = 0; } }