UISwiper.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. import {
  2. _decorator,
  3. CCFloat,
  4. Component,
  5. EventTouch,
  6. instantiate,
  7. Layout,
  8. Node,
  9. Tween,
  10. tween,
  11. UITransform,
  12. Vec3,
  13. warn,
  14. } from 'cc';
  15. const { ccclass, property } = _decorator;
  16. /**
  17. * 横向循环 Banner。
  18. *
  19. * `init` 返回所有实例化出的 Banner 节点,调用方可按下标为节点填充数据。
  20. */
  21. @ccclass('UISwiper')
  22. export class UISwiper extends Component {
  23. @property(Node)
  24. itemPrefab: Node = null;
  25. @property(Node)
  26. itemContent: Node = null;
  27. @property(Node)
  28. pointNode: Node = null;
  29. @property(Node)
  30. pointContent: Node = null;
  31. @property(Node)
  32. touchNode: Node = null;
  33. /** 相邻 itemPrefab 边缘之间的水平间隔。 */
  34. @property(CCFloat)
  35. spaceX = 0;
  36. /** 松手后切换动画的时长(秒)。 */
  37. @property(CCFloat)
  38. elasticDuration = 0.25;
  39. /** 自动切换间隔(秒);小于等于 0 时关闭自动播放。 */
  40. @property(CCFloat)
  41. autoPlay = 0;
  42. private readonly items: Node[] = [];
  43. private readonly points: Node[] = [];
  44. private current = 0;
  45. private pageSpaceX = 0;
  46. private firstItemX = 0;
  47. private contentOrigin = new Vec3();
  48. private contentOriginRecorded = false;
  49. private eventNode: Node = null;
  50. private touchStartX = 0;
  51. private touchStartY = 0;
  52. private contentStartX = 0;
  53. private lastOffsetX = 0;
  54. private horizontalGesture: boolean | null = null;
  55. private touching = false;
  56. private animating = false;
  57. /** 当前正在展示的 Banner 下标。 */
  58. get currentIndex(): number {
  59. return this.current;
  60. }
  61. /** 当前实例化出的 Banner 节点。 */
  62. get itemNodes(): readonly Node[] {
  63. return this.items;
  64. }
  65. protected onEnable(): void {
  66. this.bindTouchEvents();
  67. this.restartAutoPlay();
  68. }
  69. protected onDisable(): void {
  70. this.unbindTouchEvents();
  71. this.stopAutoPlay();
  72. this.stopAnimation();
  73. }
  74. /**
  75. * 创建指定数量的 Banner 和指示点,并回到第一页。
  76. * 重复调用时只会清理本组件上一次生成的节点。
  77. */
  78. init(count: number): Node[] {
  79. this.stopAutoPlay();
  80. this.stopAnimation();
  81. this.clearGeneratedNodes();
  82. if (!this.itemPrefab || !this.itemContent) {
  83. warn('[UISwiper] itemPrefab 和 itemContent 不能为空');
  84. return this.items;
  85. }
  86. count = Number.isFinite(count) ? Math.max(0, Math.floor(count)) : 0;
  87. this.removeItemLayout();
  88. this.recordContentOrigin();
  89. this.current = 0;
  90. for (let i = 0; i < count; i++) {
  91. const item = instantiate(this.itemPrefab);
  92. item.active = true;
  93. this.itemContent.addChild(item);
  94. item.setPosition(Vec3.ZERO);
  95. this.items.push(item);
  96. if (this.pointNode && this.pointContent) {
  97. const point = instantiate(this.pointNode);
  98. point.active = true;
  99. this.pointContent.addChild(point);
  100. point.setPosition(Vec3.ZERO);
  101. this.points.push(point);
  102. }
  103. }
  104. this.updateItemPositions();
  105. this.pointContent?.getComponent(Layout)?.updateLayout();
  106. this.updatePoints();
  107. this.setContentX(this.contentOrigin.x);
  108. this.restartAutoPlay();
  109. return this.items;
  110. }
  111. private bindTouchEvents(): void {
  112. const target = this.touchNode || this.node;
  113. if (this.eventNode === target) return;
  114. this.unbindTouchEvents();
  115. this.eventNode = target;
  116. target.on(Node.EventType.TOUCH_START, this.onTouchStart, this);
  117. target.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
  118. target.on(Node.EventType.TOUCH_END, this.onTouchEnd, this);
  119. target.on(Node.EventType.TOUCH_CANCEL, this.onTouchCancel, this);
  120. }
  121. private unbindTouchEvents(): void {
  122. if (!this.eventNode) return;
  123. this.eventNode.off(Node.EventType.TOUCH_START, this.onTouchStart, this);
  124. this.eventNode.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this);
  125. this.eventNode.off(Node.EventType.TOUCH_END, this.onTouchEnd, this);
  126. this.eventNode.off(Node.EventType.TOUCH_CANCEL, this.onTouchCancel, this);
  127. this.eventNode = null;
  128. }
  129. private onTouchStart(event: EventTouch): void {
  130. this.touching = false;
  131. if (this.items.length <= 1 || this.animating) return;
  132. this.stopAutoPlay();
  133. const location = event.getUILocation();
  134. this.touching = true;
  135. this.touchStartX = location.x;
  136. this.touchStartY = location.y;
  137. this.contentStartX = this.itemContent.position.x;
  138. this.lastOffsetX = 0;
  139. this.horizontalGesture = null;
  140. this.restoreItemPositions();
  141. }
  142. private onTouchMove(event: EventTouch): void {
  143. if (!this.touching || this.items.length <= 1 || this.animating) return;
  144. const location = event.getUILocation();
  145. let offsetX = location.x - this.touchStartX;
  146. const offsetY = location.y - this.touchStartY;
  147. if (this.horizontalGesture === null) {
  148. if (Math.abs(offsetX) < 5 && Math.abs(offsetY) < 5) return;
  149. this.horizontalGesture = Math.abs(offsetX) > Math.abs(offsetY);
  150. }
  151. if (!this.horizontalGesture) return;
  152. // 一次手势最多切换一页,避免快速拖动时越过相邻 Banner。
  153. offsetX = Math.max(-this.pageSpaceX, Math.min(this.pageSpaceX, offsetX));
  154. this.lastOffsetX = offsetX;
  155. this.prepareLoopItems(offsetX);
  156. this.setContentX(this.contentStartX + offsetX);
  157. }
  158. private onTouchEnd(event: EventTouch): void {
  159. if (!this.touching || this.items.length <= 1 || this.animating) return;
  160. this.touching = false;
  161. const offsetX = event.getUILocation().x - this.touchStartX;
  162. this.settleSwipe(offsetX);
  163. }
  164. private onTouchCancel(): void {
  165. if (!this.touching || this.items.length <= 1 || this.animating) return;
  166. this.touching = false;
  167. // 手指移出 touchNode 后通常收到 TOUCH_CANCEL,使用最后一次有效位移正常结算。
  168. this.settleSwipe(this.lastOffsetX);
  169. }
  170. private settleSwipe(offsetX: number): void {
  171. if (!this.horizontalGesture) {
  172. this.finishSwipe(0);
  173. return;
  174. }
  175. offsetX = Math.max(-this.pageSpaceX, Math.min(this.pageSpaceX, offsetX));
  176. const threshold = Math.min(this.pageSpaceX * 0.25, 80);
  177. if (Math.abs(offsetX) < threshold) {
  178. this.finishSwipe(0);
  179. return;
  180. }
  181. this.prepareLoopItems(offsetX);
  182. this.finishSwipe(offsetX < 0 ? 1 : -1);
  183. }
  184. /** direction: 1 下一页,-1 上一页,0 回弹。 */
  185. private finishSwipe(direction: -1 | 0 | 1): void {
  186. const targetX = this.contentStartX - direction * this.pageSpaceX;
  187. const complete = () => {
  188. if (direction !== 0) {
  189. this.current = (this.current + direction + this.items.length) % this.items.length;
  190. }
  191. this.restoreItemPositions();
  192. this.setContentX(this.contentOrigin.x - this.current * this.pageSpaceX);
  193. this.updatePoints();
  194. this.horizontalGesture = null;
  195. this.animating = false;
  196. this.restartAutoPlay();
  197. };
  198. // 即使 elasticDuration 被配置为 0,也保留一个可见的回弹/切换过程。
  199. const duration = Math.max(0.1, this.elasticDuration);
  200. this.stopAutoPlay();
  201. this.animating = true;
  202. tween(this.itemContent)
  203. .to(duration, { position: new Vec3(targetX, this.itemContent.position.y, this.itemContent.position.z) }, { easing: 'quadOut' })
  204. .call(complete)
  205. .start();
  206. }
  207. private onAutoPlay(): void {
  208. if (this.items.length <= 1 || this.touching || this.animating) {
  209. this.restartAutoPlay();
  210. return;
  211. }
  212. this.contentStartX = this.itemContent.position.x;
  213. this.prepareLoopItems(-this.pageSpaceX);
  214. this.finishSwipe(1);
  215. }
  216. private restartAutoPlay(): void {
  217. this.stopAutoPlay();
  218. if (
  219. !Number.isFinite(this.autoPlay)
  220. || this.autoPlay <= 0
  221. || this.items.length <= 1
  222. || !this.enabledInHierarchy
  223. ) {
  224. return;
  225. }
  226. this.scheduleOnce(this.onAutoPlay, this.autoPlay);
  227. }
  228. private stopAutoPlay(): void {
  229. this.unschedule(this.onAutoPlay);
  230. }
  231. private updateItemPositions(): void {
  232. if (this.items.length === 0) {
  233. this.pageSpaceX = 0;
  234. this.firstItemX = 0;
  235. return;
  236. }
  237. this.firstItemX = 0;
  238. this.pageSpaceX = this.resolvePageSpaceX();
  239. this.restoreItemPositions();
  240. }
  241. private resolvePageSpaceX(): number {
  242. const touchWidth = (this.touchNode || this.node).getComponent(UITransform)?.width || 0;
  243. const itemWidth = this.itemPrefab.getComponent(UITransform)?.width || 0;
  244. const pageWidth = itemWidth > 0 ? itemWidth : touchWidth;
  245. return Math.max(1, pageWidth + this.spaceX);
  246. }
  247. private prepareLoopItems(offsetX: number): void {
  248. this.restoreItemPositions();
  249. const lastIndex = this.items.length - 1;
  250. if (this.current === 0 && offsetX > 0) {
  251. this.setItemX(lastIndex, this.firstItemX - this.pageSpaceX);
  252. }
  253. if (this.current === lastIndex && offsetX < 0) {
  254. this.setItemX(0, this.firstItemX + this.items.length * this.pageSpaceX);
  255. }
  256. }
  257. private restoreItemPositions(): void {
  258. for (let i = 0; i < this.items.length; i++) {
  259. this.setItemX(i, this.firstItemX + i * this.pageSpaceX);
  260. }
  261. }
  262. private setItemX(index: number, x: number): void {
  263. const item = this.items[index];
  264. item.setPosition(x, item.position.y, item.position.z);
  265. }
  266. private updatePoints(): void {
  267. for (let i = 0; i < this.points.length; i++) {
  268. const activeNode = this.points[i].getChildByName('sptActive');
  269. if (activeNode) activeNode.active = i === this.current;
  270. }
  271. }
  272. private recordContentOrigin(): void {
  273. if (this.contentOriginRecorded) return;
  274. this.contentOrigin.set(this.itemContent.position);
  275. this.contentOrigin.x = this.resolveTouchCenterX();
  276. this.contentOriginRecorded = true;
  277. }
  278. private resolveTouchCenterX(): number {
  279. const touchTransform = (this.touchNode || this.node).getComponent(UITransform);
  280. const parentTransform = this.itemContent.parent?.getComponent(UITransform);
  281. if (!touchTransform || !parentTransform) return this.itemContent.position.x;
  282. const anchor = touchTransform.anchorPoint;
  283. const centerInTouch = new Vec3(
  284. (0.5 - anchor.x) * touchTransform.width,
  285. (0.5 - anchor.y) * touchTransform.height,
  286. 0,
  287. );
  288. const worldCenter = touchTransform.convertToWorldSpaceAR(centerInTouch);
  289. return parentTransform.convertToNodeSpaceAR(worldCenter).x;
  290. }
  291. private removeItemLayout(): void {
  292. const layout = this.itemContent.getComponent(Layout);
  293. if (!layout) return;
  294. layout.enabled = false;
  295. layout.destroy();
  296. }
  297. private setContentX(x: number): void {
  298. this.itemContent.setPosition(x, this.itemContent.position.y, this.itemContent.position.z);
  299. }
  300. private stopAnimation(): void {
  301. if (this.itemContent) Tween.stopAllByTarget(this.itemContent);
  302. this.animating = false;
  303. this.touching = false;
  304. this.horizontalGesture = null;
  305. if (this.contentOriginRecorded && this.itemContent && this.items.length > 0) {
  306. this.restoreItemPositions();
  307. this.setContentX(this.contentOrigin.x - this.current * this.pageSpaceX);
  308. }
  309. }
  310. private clearGeneratedNodes(): void {
  311. for (const item of this.items) {
  312. item.removeFromParent();
  313. item.destroy();
  314. }
  315. for (const point of this.points) {
  316. point.removeFromParent();
  317. point.destroy();
  318. }
  319. this.items.length = 0;
  320. this.points.length = 0;
  321. }
  322. }