cl-marquee.uvue 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  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, getUnit, 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,
  72. default: 100
  73. },
  74. // 图片宽度 (仅横向滚动时生效,纵向为100%)
  75. itemWidth: {
  76. type: Number,
  77. default: 150
  78. },
  79. // 间距
  80. gap: {
  81. type: Number,
  82. default: 10
  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. const style = {
  121. transform: isVertical
  122. ? `translateY(${getUnit(currentOffset.value)})`
  123. : `translateX(${getUnit(currentOffset.value)})`
  124. };
  125. // #ifdef APP
  126. if (props.direction == "vertical") {
  127. style["height"] = getUnit((props.itemHeight + props.gap) * duplicatedList.value.length);
  128. } else {
  129. style["width"] = getUnit((props.itemWidth + props.gap) * duplicatedList.value.length);
  130. }
  131. // #endif
  132. return style;
  133. });
  134. /** 图片项样式 */
  135. const itemStyle = computed(() => {
  136. const style = {};
  137. if (props.direction == "vertical") {
  138. style["height"] = getUnit(props.itemHeight);
  139. style["marginBottom"] = getUnit(props.gap);
  140. } else {
  141. style["width"] = getUnit(props.itemWidth);
  142. style["marginRight"] = getUnit(props.gap);
  143. }
  144. return style;
  145. });
  146. /** 单个项目的尺寸(包含间距) */
  147. const itemSize = computed(() => {
  148. const size = props.direction == "vertical" ? props.itemHeight : props.itemWidth;
  149. return size + props.gap;
  150. });
  151. /** 总的滚动距离 */
  152. const totalScrollDistance = computed(() => {
  153. return props.list.length * itemSize.value;
  154. });
  155. /** 动画实例 */
  156. let animation: AnimationEngine | null = null;
  157. /**
  158. * 开始动画
  159. */
  160. function start() {
  161. if (props.list.length <= 1) return;
  162. animation = createAnimation(marqueeRef.value, {
  163. duration: props.duration,
  164. timingFunction: "linear",
  165. loop: -1,
  166. frame: (progress: number) => {
  167. currentOffset.value = -progress * totalScrollDistance.value;
  168. }
  169. });
  170. animation!.play();
  171. }
  172. /**
  173. * 播放动画
  174. */
  175. function play() {
  176. if (animation != null) {
  177. animation!.play();
  178. }
  179. }
  180. /**
  181. * 暂停动画
  182. */
  183. function pause() {
  184. if (animation != null) {
  185. animation!.pause();
  186. }
  187. }
  188. /**
  189. * 停止动画
  190. */
  191. function stop() {
  192. if (animation != null) {
  193. animation!.stop();
  194. }
  195. }
  196. /**
  197. * 重置动画
  198. */
  199. function reset() {
  200. currentOffset.value = 0;
  201. if (animation != null) {
  202. animation!.stop();
  203. animation!.reset();
  204. }
  205. }
  206. onMounted(() => {
  207. setTimeout(() => {
  208. start();
  209. }, 300);
  210. watch(
  211. computed(() => [props.duration, props.itemHeight, props.itemWidth, props.gap, props.list]),
  212. () => {
  213. reset();
  214. start();
  215. }
  216. );
  217. });
  218. onUnmounted(() => {
  219. stop();
  220. });
  221. defineExpose({
  222. start,
  223. stop,
  224. reset,
  225. pause,
  226. play
  227. });
  228. </script>
  229. <style lang="scss" scoped>
  230. .cl-marquee {
  231. @apply relative;
  232. &__list {
  233. @apply flex h-full overflow-visible;
  234. &.is-horizontal {
  235. @apply flex-row;
  236. }
  237. &.is-vertical {
  238. @apply flex-col;
  239. }
  240. }
  241. &__item {
  242. @apply relative h-full;
  243. }
  244. &__image {
  245. @apply w-full h-full rounded-xl;
  246. }
  247. }
  248. </style>