cl-marquee.uvue 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  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. const emit = defineEmits(["item-click"]);
  86. // 透传属性类型定义
  87. type PassThrough = {
  88. className?: string;
  89. list?: PassThroughProps;
  90. item?: PassThroughProps;
  91. image?: PassThroughProps;
  92. };
  93. const pt = computed(() => parsePt<PassThrough>(props.pt));
  94. /** 跑马灯引用 */
  95. const marqueeRef = ref<UniElement | null>(null);
  96. /** 当前偏移量 */
  97. const currentOffset = ref(0);
  98. /** 重复的图片列表(用于无缝滚动) */
  99. const duplicatedList = computed<MarqueeItem[]>(() => {
  100. if (props.list.length == 0) return [];
  101. const originalItems = props.list.map(
  102. (url, index) =>
  103. ({
  104. url,
  105. originalIndex: index
  106. }) as MarqueeItem
  107. );
  108. // 复制一份用于无缝滚动
  109. const duplicatedItems = props.list.map(
  110. (url, index) =>
  111. ({
  112. url,
  113. originalIndex: index
  114. }) as MarqueeItem
  115. );
  116. return [...originalItems, ...duplicatedItems] as MarqueeItem[];
  117. });
  118. /** 容器样式 */
  119. const listStyle = computed(() => {
  120. const isVertical = props.direction == "vertical";
  121. return {
  122. transform: isVertical
  123. ? `translateY(${currentOffset.value}px)`
  124. : `translateX(${currentOffset.value}px)`
  125. };
  126. });
  127. /** 图片项样式 */
  128. const itemStyle = computed(() => {
  129. const style = {};
  130. const gap = getPx(props.gap) + "px";
  131. if (props.direction == "vertical") {
  132. style["height"] = getPx(props.itemHeight) + "px";
  133. style["marginBottom"] = gap;
  134. } else {
  135. style["width"] = getPx(props.itemWidth) + "px";
  136. style["marginRight"] = gap;
  137. }
  138. return style;
  139. });
  140. /** 单个项目的尺寸(包含间距) */
  141. const itemSize = computed(() => {
  142. const size = props.direction == "vertical" ? props.itemHeight : props.itemWidth;
  143. return getPx(size) + getPx(props.gap);
  144. });
  145. /** 总的滚动距离 */
  146. const totalScrollDistance = computed(() => {
  147. return props.list.length * itemSize.value;
  148. });
  149. /** 动画实例 */
  150. let animation: AnimationEngine | null = null;
  151. /**
  152. * 开始动画
  153. */
  154. function start() {
  155. if (props.list.length <= 1) return;
  156. animation = createAnimation(marqueeRef.value, {
  157. duration: props.duration,
  158. timingFunction: "linear",
  159. loop: -1,
  160. frame: (progress: number) => {
  161. currentOffset.value = -progress * totalScrollDistance.value;
  162. }
  163. });
  164. animation!.play();
  165. }
  166. /**
  167. * 播放动画
  168. */
  169. function play() {
  170. if (animation != null) {
  171. animation!.play();
  172. }
  173. }
  174. /**
  175. * 暂停动画
  176. */
  177. function pause() {
  178. if (animation != null) {
  179. animation!.pause();
  180. }
  181. }
  182. /**
  183. * 停止动画
  184. */
  185. function stop() {
  186. if (animation != null) {
  187. animation!.stop();
  188. }
  189. }
  190. /**
  191. * 重置动画
  192. */
  193. function reset() {
  194. currentOffset.value = 0;
  195. if (animation != null) {
  196. animation!.stop();
  197. animation!.reset();
  198. }
  199. }
  200. onMounted(() => {
  201. setTimeout(() => {
  202. start();
  203. }, 300);
  204. watch(
  205. computed(() => [props.duration, props.itemHeight, props.itemWidth, props.gap, props.list]),
  206. () => {
  207. reset();
  208. start();
  209. }
  210. );
  211. });
  212. onUnmounted(() => {
  213. stop();
  214. });
  215. defineExpose({
  216. start,
  217. stop,
  218. reset,
  219. pause,
  220. play
  221. });
  222. </script>
  223. <style lang="scss" scoped>
  224. .cl-marquee {
  225. @apply relative;
  226. &__list {
  227. @apply flex h-full overflow-visible;
  228. &.is-horizontal {
  229. @apply flex-row;
  230. }
  231. &.is-vertical {
  232. @apply flex-col;
  233. }
  234. }
  235. &__item {
  236. @apply relative h-full;
  237. }
  238. &__image {
  239. @apply w-full h-full rounded-xl;
  240. }
  241. }
  242. </style>