| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386 |
- 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;
- }
- }
|