Преглед изворни кода

添加 cl-marquee 跑马灯组件

icssoa пре 6 месеци
родитељ
комит
ed3bb84a0e

+ 86 - 24
cool/utils/parse.ts

@@ -120,23 +120,6 @@ export function parseToObject<T>(data: T): UTSJSONObject {
 }
 
 /**
- * 将数值或字符串转换为rpx单位的字符串
- * @param val 要转换的值,可以是数字或字符串
- * @returns 转换后的rpx单位字符串
- * @example
- * parseRpx(10) // 返回 '10rpx'
- * parseRpx('10rpx') // 返回 '10rpx'
- * parseRpx('10px') // 返回 '10px'
- */
-export const parseRpx = (val: number | string): string => {
-	if (typeof val == "number") {
-		return val + "rpx";
-	}
-
-	return val;
-};
-
-/**
  * 将rpx单位转换为px单位
  * @param rpx 要转换的rpx值
  * @returns 转换后的px值
@@ -167,16 +150,95 @@ export const px2rpx = (px: number): number => {
 };
 
 /**
- * 解析px单位,支持rpx单位转换
- * @param val 要解析的值,可以是数字或字符串
- * @returns 解析后的px单位
+ * 将数值或字符串转换为rpx单位的字符串
+ * @param val 要转换的值,可以是数字或字符串
+ * @returns 转换后的rpx单位字符串
+ * @example
+ * parseRpx(10) // 返回 '10rpx'
+ * parseRpx('10rpx') // 返回 '10rpx'
+ * parseRpx('10px') // 返回 '10px'
  */
-export const getPx = (val: string) => {
-	const num = parseInt(val);
+export const parseRpx = (val: number | string): string => {
+	if (typeof val == "number") {
+		return val + "rpx";
+	}
+
+	return val;
+};
+
+/**
+ * 示例: 获取数值部分
+ * @example
+ * getNum("10rpx") // 返回 10
+ * getNum("10px")  // 返回 10
+ * getNum("10")    // 返回 10
+ * getNum("-5.5px") // 返回 -5.5
+ * @param val - 输入值,例如 "10rpx"、"10px"、"10"
+ * @returns number - 返回提取的数值
+ */
+export const getNum = (val: string): number => {
+	// 使用正则提取数字部分,支持小数和负数
+	const match = val.match(/-?\d+(\.\d+)?/);
+	return match != null ? parseFloat(match[0] ?? "0") : 0;
+};
+
+/**
+ * 示例: 获取单位
+ * @example
+ * getUnit("10rpx") // 返回 "rpx"
+ * getUnit("10px")  // 返回 "px"
+ * @param val - 输入值,例如 "10rpx"、"10px"
+ * @returns string - 返回单位字符串,如 "rpx" 或 "px"
+ */
+export const getUnit = (val: string): string => {
+	const num = getNum(val);
+	return val.replace(`${num}`, "");
+};
+
+/**
+ * 示例: 转换为 rpx 值
+ * @example
+ * getRpx("10rpx") // 返回 10
+ * getRpx("10px")  // 返回 px2rpx(10)
+ * getRpx(10)      // 返回 10
+ * @param val - 输入值,可以是 "10rpx"、"10px" 或数字 10
+ * @returns number - 返回对应的 rpx 数值
+ */
+export const getRpx = (val: number | string): number => {
+	if (typeof val == "number") {
+		return val;
+	}
+
+	const num = getNum(val);
+	const unit = getUnit(val);
+
+	if (unit == "px") {
+		return px2rpx(num);
+	}
+
+	return num;
+};
+
+/**
+ * 示例: 转换为 px 值
+ * @example
+ * getPx("10rpx") // 返回 rpx2px(10)
+ * getPx("10px")  // 返回 10
+ * getPx(10)      // 返回 rpx2px(10)
+ * @param val - 输入值,可以是 "10rpx"、"10px" 或数字 10
+ * @returns number - 返回对应的 px 数值
+ */
+export const getPx = (val: string | number) => {
+	if (typeof val == "number") {
+		return rpx2px(val);
+	}
+
+	const num = getNum(val);
+	const unit = getUnit(val);
 
-	if (val.includes("rpx")) {
+	if (unit == "rpx") {
 		return rpx2px(num);
 	}
 
-	return Math.floor(num);
+	return num;
 };

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "cool-unix",
-	"version": "8.0.23",
+	"version": "8.0.24",
 	"license": "MIT",
 	"scripts": {
 		"build-ui": "node ./uni_modules/cool-ui/scripts/generate-types.js",

+ 6 - 0
pages.json

@@ -293,6 +293,12 @@
 					}
 				},
 				{
+					"path": "data/marquee",
+					"style": {
+						"navigationBarTitleText": "Marquee 跑马灯"
+					}
+				},
+				{
 					"path": "data/pagination",
 					"style": {
 						"navigationBarTitleText": "Pagination 分页"

+ 70 - 0
pages/demo/data/marquee.uvue

@@ -0,0 +1,70 @@
+<template>
+	<cl-page>
+		<view class="p-3">
+			<demo-item :label="t('横向滚动')">
+				<cl-marquee
+					:list="list"
+					direction="horizontal"
+					:item-height="200"
+					:item-width="360"
+					:pt="{
+						className: 'h-[200rpx] rounded-xl'
+					}"
+				></cl-marquee>
+			</demo-item>
+
+			<demo-item :label="t('纵向滚动')">
+				<cl-marquee
+					ref="marqueeRef"
+					:list="list"
+					direction="vertical"
+					:item-height="260"
+					:duration="isSpeed ? 2000 : 5000"
+					:pt="{
+						className: 'h-[500rpx] rounded-xl'
+					}"
+				></cl-marquee>
+
+				<cl-list
+					border
+					:pt="{
+						className: 'mt-5'
+					}"
+				>
+					<cl-list-item :label="t('快一点')">
+						<cl-switch v-model="isSpeed"></cl-switch>
+					</cl-list-item>
+
+					<cl-list-item :label="t('暂停')">
+						<cl-switch v-model="isPause" @change="onPauseChange"></cl-switch>
+					</cl-list-item>
+				</cl-list>
+			</demo-item>
+		</view>
+	</cl-page>
+</template>
+
+<script lang="ts" setup>
+import DemoItem from "../components/item.uvue";
+import { t } from "@/locale";
+import { ref } from "vue";
+
+const marqueeRef = ref<ClMarqueeComponentPublicInstance | null>(null);
+
+const list = ref<string[]>([
+	"https://uni-docs.cool-js.com/demo/pages/demo/static/bg1.png",
+	"https://uni-docs.cool-js.com/demo/pages/demo/static/bg2.png",
+	"https://uni-docs.cool-js.com/demo/pages/demo/static/bg3.png"
+]);
+
+const isSpeed = ref(false);
+const isPause = ref(false);
+
+function onPauseChange(value: boolean) {
+	if (value) {
+		marqueeRef.value!.pause();
+	} else {
+		marqueeRef.value!.play();
+	}
+}
+</script>

+ 5 - 0
pages/index/home.uvue

@@ -302,6 +302,11 @@ const data = computed<Item[]>(() => {
 					path: "/pages/demo/data/banner"
 				},
 				{
+					label: t("跑马灯"),
+					icon: "film-line",
+					path: "/pages/demo/data/marquee"
+				},
+				{
 					label: t("分页"),
 					icon: "page-separator",
 					path: "/pages/demo/data/pagination"

+ 283 - 0
uni_modules/cool-ui/components/cl-marquee/cl-marquee.uvue

@@ -0,0 +1,283 @@
+<template>
+	<view ref="marqueeRef" class="cl-marquee" :class="[pt.className]">
+		<view
+			class="cl-marquee__list"
+			:class="[
+				pt.list?.className,
+				{
+					'is-vertical': direction == 'vertical',
+					'is-horizontal': direction == 'horizontal'
+				}
+			]"
+			:style="listStyle"
+		>
+			<!-- 渲染两份图片列表实现无缝滚动 -->
+			<view
+				class="cl-marquee__item"
+				v-for="(item, index) in duplicatedList"
+				:key="`${item.url}-${index}`"
+				:class="[pt.item?.className]"
+				:style="itemStyle"
+			>
+				<slot name="item" :item="item" :index="item.originalIndex">
+					<image
+						:src="item.url"
+						mode="aspectFill"
+						class="cl-marquee__image"
+						:class="[pt.image?.className]"
+					></image>
+				</slot>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, onMounted, onUnmounted, type PropType, watch } from "vue";
+import { AnimationEngine, createAnimation, getPx, parsePt } from "@/cool";
+import type { PassThroughProps } from "../../types";
+
+type MarqueeItem = {
+	url: string;
+	originalIndex: number;
+};
+
+defineOptions({
+	name: "cl-marquee"
+});
+
+defineSlots<{
+	item(props: { item: MarqueeItem; index: number }): any;
+}>();
+
+const props = defineProps({
+	// 透传属性
+	pt: {
+		type: Object,
+		default: () => ({})
+	},
+	// 图片列表
+	list: {
+		type: Array as PropType<string[]>,
+		default: () => []
+	},
+	// 滚动方向
+	direction: {
+		type: String as PropType<"horizontal" | "vertical">,
+		default: "horizontal"
+	},
+	// 一次滚动的持续时间
+	duration: {
+		type: Number,
+		default: 5000
+	},
+	// 图片高度
+	itemHeight: {
+		type: [Number, String],
+		default: 200
+	},
+	// 图片宽度 (仅横向滚动时生效,纵向为100%)
+	itemWidth: {
+		type: [Number, String],
+		default: 300
+	},
+	// 间距
+	gap: {
+		type: [Number, String],
+		default: 20
+	}
+});
+
+const emit = defineEmits(["item-click"]);
+
+// 透传属性类型定义
+type PassThrough = {
+	className?: string;
+	list?: PassThroughProps;
+	item?: PassThroughProps;
+	image?: PassThroughProps;
+};
+
+const pt = computed(() => parsePt<PassThrough>(props.pt));
+
+/** 跑马灯引用 */
+const marqueeRef = ref<UniElement | null>(null);
+
+/** 当前偏移量 */
+const currentOffset = ref(0);
+
+/** 重复的图片列表(用于无缝滚动) */
+const duplicatedList = computed<MarqueeItem[]>(() => {
+	if (props.list.length == 0) return [];
+
+	const originalItems = props.list.map(
+		(url, index) =>
+			({
+				url,
+				originalIndex: index
+			}) as MarqueeItem
+	);
+
+	// 复制一份用于无缝滚动
+	const duplicatedItems = props.list.map(
+		(url, index) =>
+			({
+				url,
+				originalIndex: index
+			}) as MarqueeItem
+	);
+
+	return [...originalItems, ...duplicatedItems] as MarqueeItem[];
+});
+
+/** 容器样式 */
+const listStyle = computed(() => {
+	const isVertical = props.direction == "vertical";
+
+	return {
+		transform: isVertical
+			? `translateY(${currentOffset.value}px)`
+			: `translateX(${currentOffset.value}px)`
+	};
+});
+
+/** 图片项样式 */
+const itemStyle = computed(() => {
+	const style = {};
+
+	const gap = getPx(props.gap) + "px";
+
+	if (props.direction == "vertical") {
+		style["height"] = getPx(props.itemHeight) + "px";
+		style["marginBottom"] = gap;
+	} else {
+		style["width"] = getPx(props.itemWidth) + "px";
+		style["marginRight"] = gap;
+	}
+
+	return style;
+});
+
+/** 单个项目的尺寸(包含间距) */
+const itemSize = computed(() => {
+	const size = props.direction == "vertical" ? props.itemHeight : props.itemWidth;
+	return getPx(size) + getPx(props.gap);
+});
+
+/** 总的滚动距离 */
+const totalScrollDistance = computed(() => {
+	return props.list.length * itemSize.value;
+});
+
+/** 动画实例 */
+let animation: AnimationEngine | null = null;
+
+/**
+ * 开始动画
+ */
+function start() {
+	if (props.list.length <= 1) return;
+
+	animation = createAnimation(marqueeRef.value, {
+		duration: props.duration,
+		timingFunction: "linear",
+		loop: -1,
+		frame: (progress: number) => {
+			currentOffset.value = -progress * totalScrollDistance.value;
+		}
+	});
+
+	animation!.play();
+}
+
+/**
+ * 播放动画
+ */
+function play() {
+	if (animation != null) {
+		animation!.play();
+	}
+}
+
+/**
+ * 暂停动画
+ */
+function pause() {
+	if (animation != null) {
+		animation!.pause();
+	}
+}
+
+/**
+ * 停止动画
+ */
+function stop() {
+	if (animation != null) {
+		animation!.stop();
+	}
+}
+
+/**
+ * 重置动画
+ */
+function reset() {
+	currentOffset.value = 0;
+
+	if (animation != null) {
+		animation!.stop();
+		animation!.reset();
+	}
+}
+
+onMounted(() => {
+	setTimeout(() => {
+		start();
+	}, 300);
+
+	watch(
+		computed(() => [props.duration, props.itemHeight, props.itemWidth, props.gap, props.list]),
+		() => {
+			reset();
+			start();
+		}
+	);
+});
+
+onUnmounted(() => {
+	stop();
+});
+
+defineExpose({
+	start,
+	stop,
+	reset,
+	pause,
+	play
+});
+</script>
+
+<style lang="scss" scoped>
+.cl-marquee {
+	@apply relative;
+
+	&__list {
+		@apply flex h-full overflow-visible;
+
+		&.is-horizontal {
+			@apply flex-row;
+		}
+
+		&.is-vertical {
+			@apply flex-col;
+		}
+	}
+
+	&__item {
+		@apply relative h-full;
+	}
+
+	&__image {
+		@apply w-full h-full rounded-xl;
+	}
+}
+</style>

+ 19 - 0
uni_modules/cool-ui/components/cl-marquee/props.ts

@@ -0,0 +1,19 @@
+import type { PassThroughProps } from "../../types";
+
+export type ClMarqueePassThrough = {
+	className?: string;
+	list?: PassThroughProps;
+	item?: PassThroughProps;
+	image?: PassThroughProps;
+};
+
+export type ClMarqueeProps = {
+	className?: string;
+	pt?: ClMarqueePassThrough;
+	list?: string[];
+	direction?: "horizontal" | "vertical";
+	duration?: number;
+	itemHeight?: any;
+	itemWidth?: any;
+	gap?: any;
+};

+ 2 - 0
uni_modules/cool-ui/index.d.ts

@@ -38,6 +38,7 @@ import type { ClListItemProps, ClListItemPassThrough } from "./components/cl-lis
 import type { ClListViewProps, ClListViewPassThrough } from "./components/cl-list-view/props";
 import type { ClLoadingProps, ClLoadingPassThrough } from "./components/cl-loading/props";
 import type { ClLoadmoreProps, ClLoadmorePassThrough } from "./components/cl-loadmore/props";
+import type { ClMarqueeProps, ClMarqueePassThrough } from "./components/cl-marquee/props";
 import type { ClNoticebarProps, ClNoticebarPassThrough } from "./components/cl-noticebar/props";
 import type { ClPageProps } from "./components/cl-page/props";
 import type { ClPageThemeProps } from "./components/cl-page-theme/props";
@@ -116,6 +117,7 @@ declare module "vue" {
 		"cl-list-view": (typeof import('./components/cl-list-view/cl-list-view.uvue')['default']) & import('vue').DefineComponent<ClListViewProps>;
 		"cl-loading": (typeof import('./components/cl-loading/cl-loading.uvue')['default']) & import('vue').DefineComponent<ClLoadingProps>;
 		"cl-loadmore": (typeof import('./components/cl-loadmore/cl-loadmore.uvue')['default']) & import('vue').DefineComponent<ClLoadmoreProps>;
+		"cl-marquee": (typeof import('./components/cl-marquee/cl-marquee.uvue')['default']) & import('vue').DefineComponent<ClMarqueeProps>;
 		"cl-noticebar": (typeof import('./components/cl-noticebar/cl-noticebar.uvue')['default']) & import('vue').DefineComponent<ClNoticebarProps>;
 		"cl-page": (typeof import('./components/cl-page/cl-page.uvue')['default']) & import('vue').DefineComponent<ClPageProps>;
 		"cl-page-theme": (typeof import('./components/cl-page-theme/cl-page-theme.uvue')['default']) & import('vue').DefineComponent<ClPageThemeProps>;

+ 8 - 0
uni_modules/cool-ui/types/component.d.ts

@@ -230,3 +230,11 @@ declare type ClCalendarComponentPublicInstance = {
 	open(cb: ((value: string | string[]) => void) | null = null): void;
 	close(): void;
 };
+
+declare type ClMarqueeComponentPublicInstance = {
+	play(): void;
+	pause(): void;
+	start(): void;
+	stop(): void;
+	reset(): void;
+};

+ 27 - 0
uni_modules/cool-ui/types/index.ts

@@ -210,3 +210,30 @@ export type ClCalendarDateConfig = {
 	disabled?: boolean;
 	color?: string;
 };
+
+export type ClMarqueeDirection = "horizontal" | "vertical";
+
+export type ClMarqueeItem = {
+	url: string;
+	originalIndex: number;
+};
+
+export type ClMarqueePassThrough = {
+	className?: string;
+	container?: PassThroughProps;
+	item?: PassThroughProps;
+	image?: PassThroughProps;
+};
+
+export type ClMarqueeProps = {
+	className?: string;
+	pt?: ClMarqueePassThrough;
+	list?: string[];
+	direction?: ClMarqueeDirection;
+	speed?: number;
+	pause?: boolean;
+	pauseOnHover?: boolean;
+	itemHeight?: number;
+	itemWidth?: number;
+	gap?: number;
+};