cl-marquee.uvue 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. <template>
  2. <view ref="marqueeRef" class="cl-marquee" :class="[pt.className]">
  3. <view
  4. class="cl-marquee__list"
  5. :class="[
  6. pt.list?.className,
  7. {
  8. 'is-vertical': direction == 'vertical',
  9. 'is-horizontal': direction == 'horizontal'
  10. }
  11. ]"
  12. :style="listStyle"
  13. >
  14. <!-- 渲染两份图片列表实现无缝滚动 -->
  15. <view
  16. class="cl-marquee__item"
  17. v-for="(item, index) in duplicatedList"
  18. :key="`${item.url}-${index}`"
  19. :class="[pt.item?.className]"
  20. :style="itemStyle"
  21. >
  22. <slot name="item" :item="item" :index="item.originalIndex">
  23. <image
  24. :src="item.url"
  25. mode="aspectFill"
  26. class="cl-marquee__image"
  27. :class="[pt.image?.className]"
  28. ></image>
  29. </slot>
  30. </view>
  31. </view>
  32. </view>
  33. </template>
  34. <script setup lang="ts">
  35. import { computed, ref, onMounted, onUnmounted, type PropType, watch } from "vue";
  36. import { AnimationEngine, createAnimation, getPx, parsePt } from "@/cool";
  37. import type { PassThroughProps } from "../../types";
  38. type MarqueeItem = {
  39. url: string;
  40. originalIndex: number;
  41. };
  42. defineOptions({
  43. name: "cl-marquee"
  44. });
  45. defineSlots<{
  46. item(props: { item: MarqueeItem; index: number }): any;
  47. }>();
  48. const props = defineProps({
  49. // 透传属性
  50. pt: {
  51. type: Object,
  52. default: () => ({})
  53. },
  54. // 图片列表
  55. list: {
  56. type: Array as PropType<string[]>,
  57. default: () => []
  58. },
  59. // 滚动方向
  60. direction: {
  61. type: String as PropType<"horizontal" | "vertical">,
  62. default: "horizontal"
  63. },
  64. // 一次滚动的持续时间
  65. duration: {
  66. type: Number,
  67. default: 5000
  68. },
  69. // 图片高度
  70. itemHeight: {
  71. type: [Number, String],
  72. default: 200
  73. },
  74. // 图片宽度 (仅横向滚动时生效,纵向为100%)
  75. itemWidth: {
  76. type: [Number, String],
  77. default: 300
  78. },
  79. // 间距
  80. gap: {
  81. type: [Number, String],
  82. default: 20
  83. }
  84. });
  85. // 透传属性类型定义
  86. type PassThrough = {
  87. className?: string;
  88. list?: PassThroughProps;
  89. item?: PassThroughProps;
  90. image?: PassThroughProps;
  91. };
  92. const pt = computed(() => parsePt<PassThrough>(props.pt));
  93. /** 跑马灯引用 */
  94. const marqueeRef = ref<UniElement | null>(null);
  95. /** 当前偏移量 */
  96. const currentOffset = ref(0);
  97. /** 重复的图片列表(用于无缝滚动) */
  98. const duplicatedList = computed<MarqueeItem[]>(() => {
  99. if (props.list.length == 0) return [];
  100. const originalItems = props.list.map(
  101. (url, index) =>
  102. ({
  103. url,
  104. originalIndex: index
  105. }) as MarqueeItem
  106. );
  107. // 复制一份用于无缝滚动
  108. const duplicatedItems = props.list.map(
  109. (url, index) =>
  110. ({
  111. url,
  112. originalIndex: index
  113. }) as MarqueeItem
  114. );
  115. return [...originalItems, ...duplicatedItems] as MarqueeItem[];
  116. });
  117. /** 容器样式 */
  118. const listStyle = computed(() => {
  119. const isVertical = props.direction == "vertical";
  120. return {
  121. transform: isVertical
  122. ? `translateY(${currentOffset.value}px)`
  123. : `translateX(${currentOffset.value}px)`
  124. };
  125. });
  126. /** 图片项样式 */
  127. const itemStyle = computed(() => {
  128. const style = {};
  129. const gap = `${getPx(props.gap)}px`;
  130. if (props.direction == "vertical") {
  131. style["height"] = `${getPx(props.itemHeight)}px`;
  132. style["marginBottom"] = gap;
  133. } else {
  134. style["width"] = `${getPx(props.itemWidth)}px`;
  135. style["marginRight"] = gap;
  136. }
  137. return style;
  138. });
  139. /** 单个项目的尺寸(包含间距) */
  140. const itemSize = computed(() => {
  141. const size = props.direction == "vertical" ? props.itemHeight : props.itemWidth;
  142. return getPx(size) + getPx(props.gap);
  143. });
  144. /** 总的滚动距离 */
  145. const totalScrollDistance = computed(() => {
  146. return props.list.length * itemSize.value;
  147. });
  148. /** 动画实例 */
  149. let animation: AnimationEngine | null = null;
  150. /**
  151. * 开始动画
  152. */
  153. function start() {
  154. if (props.list.length <= 1) return;
  155. animation = createAnimation(marqueeRef.value, {
  156. duration: props.duration,
  157. timingFunction: "linear",
  158. loop: -1,
  159. frame: (progress: number) => {
  160. currentOffset.value = -progress * totalScrollDistance.value;
  161. }
  162. });
  163. animation!.play();
  164. }
  165. /**
  166. * 播放动画
  167. */
  168. function play() {
  169. if (animation != null) {
  170. animation!.play();
  171. }
  172. }
  173. /**
  174. * 暂停动画
  175. */
  176. function pause() {
  177. if (animation != null) {
  178. animation!.pause();
  179. }
  180. }
  181. /**
  182. * 停止动画
  183. */
  184. function stop() {
  185. if (animation != null) {
  186. animation!.stop();
  187. }
  188. }
  189. /**
  190. * 重置动画
  191. */
  192. function reset() {
  193. currentOffset.value = 0;
  194. if (animation != null) {
  195. animation!.stop();
  196. animation!.reset();
  197. }
  198. }
  199. onMounted(() => {
  200. setTimeout(() => {
  201. start();
  202. }, 300);
  203. watch(
  204. computed(() => [props.duration, props.itemHeight, props.itemWidth, props.gap, props.list]),
  205. () => {
  206. reset();
  207. start();
  208. }
  209. );
  210. });
  211. onUnmounted(() => {
  212. stop();
  213. });
  214. defineExpose({
  215. start,
  216. stop,
  217. reset,
  218. pause,
  219. play
  220. });
  221. </script>
  222. <style lang="scss" scoped>
  223. .cl-marquee {
  224. @apply relative;
  225. &__list {
  226. @apply flex h-full overflow-visible;
  227. &.is-horizontal {
  228. @apply flex-row;
  229. }
  230. &.is-vertical {
  231. @apply flex-col;
  232. }
  233. }
  234. &__item {
  235. @apply relative h-full;
  236. }
  237. &__image {
  238. @apply w-full h-full rounded-xl;
  239. }
  240. }
  241. </style>