| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485 |
- <template>
- <view
- class="cl-banner"
- :class="[pt.className]"
- :style="{
- height: parseRpx(height)
- }"
- @touchstart="onTouchStart"
- @touchmove="onTouchMove"
- @touchend="onTouchEnd"
- @touchcancel="onTouchEnd"
- >
- <view
- class="cl-banner__list"
- :style="{
- transform: `translateX(${slideOffset}px)`,
- transitionDuration: isAnimating ? '0.3s' : '0s'
- }"
- >
- <view
- class="cl-banner__item"
- v-for="(item, index) in list"
- :key="index"
- :class="[
- pt.item?.className,
- `${item.isActive ? `${pt.itemActive?.className}` : ''}`
- ]"
- :style="{
- width: `${getSlideWidth(index)}px`
- }"
- @tap="handleSlideClick(index)"
- >
- <slot :item="item" :index="index">
- <image
- :src="item.url"
- :mode="imageMode"
- class="cl-banner__item-image"
- :class="[pt.image?.className]"
- ></image>
- </slot>
- </view>
- </view>
- <view class="cl-banner__dots" :class="[pt.dots?.className]" v-if="showDots">
- <view
- class="cl-banner__dots-item"
- v-for="(item, index) in list"
- :key="index"
- :class="[
- {
- 'is-active': item.isActive
- },
- pt.dot?.className,
- `${item.isActive ? `${pt.dotActive?.className}` : ''}`
- ]"
- ></view>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { computed, ref, onMounted, watch, getCurrentInstance, type PropType } from "vue";
- import { parsePt, parseRpx } from "@/cool";
- import type { PassThroughProps } from "../../types";
- type Item = {
- url: string;
- isActive: boolean;
- };
- defineOptions({
- name: "cl-banner"
- });
- defineSlots<{
- item(props: { item: Item; index: number }): any;
- }>();
- const props = defineProps({
- // 透传属性
- pt: {
- type: Object,
- default: () => ({})
- },
- // 轮播项列表
- list: {
- type: Array as PropType<string[]>,
- default: () => []
- },
- // 上一个轮播项的左边距
- previousMargin: {
- type: Number,
- default: 0
- },
- // 下一个轮播项的右边距
- nextMargin: {
- type: Number,
- default: 0
- },
- // 是否自动轮播
- autoplay: {
- type: Boolean,
- default: true
- },
- // 自动轮播间隔时间(ms)
- interval: {
- type: Number,
- default: 5000
- },
- // 是否显示指示器
- showDots: {
- type: Boolean,
- default: true
- },
- // 是否禁用触摸
- disableTouch: {
- type: Boolean,
- default: false
- },
- // 高度
- height: {
- type: [Number, String],
- default: 300
- },
- // 图片模式
- imageMode: {
- type: String,
- default: "aspectFill"
- }
- });
- const emit = defineEmits(["change", "item-tap"]);
- const { proxy } = getCurrentInstance()!;
- // 透传属性类型定义
- type PassThrough = {
- className?: string;
- item?: PassThroughProps;
- itemActive?: PassThroughProps;
- image?: PassThroughProps;
- dots?: PassThroughProps;
- dot?: PassThroughProps;
- dotActive?: PassThroughProps;
- };
- const pt = computed(() => parsePt<PassThrough>(props.pt));
- /** 当前激活的轮播项索引 */
- const activeIndex = ref(0);
- /** 轮播项列表 */
- const list = computed<Item[]>(() => {
- return props.list.map((e, i) => {
- return {
- url: e,
- isActive: i == activeIndex.value
- } as Item;
- });
- });
- /** 轮播容器的水平偏移量(px) */
- const slideOffset = ref(0);
- /** 是否正在执行动画过渡 */
- const isAnimating = ref(false);
- /** 轮播容器的总宽度(px) */
- const bannerWidth = ref(0);
- /** 单个轮播项的宽度(px) - 用于缓存计算结果 */
- const slideWidth = ref(0);
- /** 触摸开始时的X坐标 */
- const touchStartPoint = ref(0);
- /** 触摸开始时的时间戳 */
- const touchStartTimestamp = ref(0);
- /** 触摸开始时的初始偏移量 */
- const initialOffset = ref(0);
- /** 是否正在触摸中 */
- const isTouching = ref(false);
- /** 位置更新防抖定时器 */
- let positionUpdateTimer: number = 0;
- /**
- * 更新轮播容器的位置
- * 根据当前激活索引计算并设置容器的偏移量
- */
- function updateSlidePosition() {
- if (bannerWidth.value == 0) return;
- // 防抖处理,避免频繁更新
- if (positionUpdateTimer != 0) {
- clearTimeout(positionUpdateTimer);
- }
- // @ts-ignore
- positionUpdateTimer = setTimeout(() => {
- // 计算累积偏移量,考虑每个位置的动态边距
- let totalOffset = 0;
- // 遍历当前索引之前的所有项,累加它们的宽度
- for (let i = 0; i < activeIndex.value; i++) {
- const itemPreviousMargin = i == 0 ? 0 : props.previousMargin;
- const itemNextMargin = i == props.list.length - 1 ? 0 : props.nextMargin;
- const itemWidthAtIndex = bannerWidth.value - itemPreviousMargin - itemNextMargin;
- totalOffset += itemWidthAtIndex;
- }
- // 当前项的左边距
- const currentPreviousMargin = activeIndex.value == 0 ? 0 : props.previousMargin;
- // 设置最终的偏移量:负方向移动累积宽度,然后加上当前项的左边距
- slideOffset.value = -totalOffset + currentPreviousMargin;
- positionUpdateTimer = 0;
- }, 10);
- }
- /**
- * 获取指定索引轮播项的宽度
- * @param index 轮播项索引
- * @returns 轮播项宽度(px)
- */
- function getSlideWidth(index: number): number {
- // 动态计算每个项的宽度,考虑边距
- const itemPreviousMargin = index == 0 ? 0 : props.previousMargin;
- const itemNextMargin = index == props.list.length - 1 ? 0 : props.nextMargin;
- return bannerWidth.value - itemPreviousMargin - itemNextMargin;
- }
- /**
- * 计算并缓存轮播项宽度
- * 使用固定的基础宽度计算,避免动态变化导致的性能问题
- */
- function calculateSlideWidth() {
- const baseWidth = bannerWidth.value - props.previousMargin - props.nextMargin;
- slideWidth.value = baseWidth;
- }
- /**
- * 测量轮播容器的尺寸信息
- * 获取容器宽度并初始化相关计算
- */
- function getRect() {
- uni.createSelectorQuery()
- .in(proxy)
- .select(".cl-banner")
- .boundingClientRect((node) => {
- bannerWidth.value = (node as NodeInfo).width ?? 0;
- // 重新计算宽度和位置
- calculateSlideWidth();
- updateSlidePosition();
- })
- .exec();
- }
- /** 自动轮播定时器 */
- let autoplayTimer: number = 0;
- /**
- * 清除自动轮播定时器
- */
- function clearAutoplay() {
- if (autoplayTimer != 0) {
- clearInterval(autoplayTimer);
- autoplayTimer = 0;
- }
- }
- /**
- * 启动自动轮播
- */
- function startAutoplay() {
- if (props.list.length <= 1) return;
- if (props.autoplay) {
- clearAutoplay();
- // 只有在非触摸状态下才启动自动轮播
- if (!isTouching.value) {
- isAnimating.value = true;
- // @ts-ignore
- autoplayTimer = setInterval(() => {
- // 再次检查是否在触摸中,避免触摸时自动切换
- if (!isTouching.value) {
- if (activeIndex.value >= props.list.length - 1) {
- activeIndex.value = 0;
- } else {
- activeIndex.value++;
- }
- }
- }, props.interval);
- }
- }
- }
- // 触摸起始Y坐标
- let touchStartY = 0;
- // 横向滑动参数
- let touchHorizontal = 0;
- /**
- * 处理触摸开始事件
- * 记录触摸起始状态,准备手势识别
- * @param e 触摸事件对象
- */
- function onTouchStart(e: TouchEvent) {
- // 如果禁用触摸,则不进行任何操作
- if (props.disableTouch) return;
- // 单项或空列表不支持滑动
- if (props.list.length <= 1) return;
- // 设置触摸状态
- isTouching.value = true;
- // 清除自动轮播
- clearAutoplay();
- // 禁用动画,开始手势跟踪
- isAnimating.value = false;
- touchStartPoint.value = e.touches[0].clientX;
- touchStartY = e.touches[0].clientY;
- touchHorizontal = 0;
- touchStartTimestamp.value = Date.now();
- initialOffset.value = slideOffset.value;
- }
- /**
- * 处理触摸移动事件
- * 实时更新容器位置,实现跟手效果
- * @param e 触摸事件对象
- */
- function onTouchMove(e: TouchEvent) {
- if (props.list.length <= 1 || props.disableTouch || !isTouching.value) return;
- const x = touchStartPoint.value - e.touches[0].clientX;
- if (touchHorizontal == 0) {
- // 只在horizontal=0时判断一次
- const y = touchStartY - e.touches[0].clientY;
- if (Math.abs(x) > Math.abs(y)) {
- // 如果x轴移动距离大于y轴移动距离则表明是横向移动手势
- touchHorizontal = 1;
- }
- if (touchHorizontal == 1) {
- // 如果是横向移动手势,则阻止默认行为(防止页面滚动)
- e.preventDefault();
- }
- }
- // 横向移动时才处理
- if (touchHorizontal != 1) {
- return;
- }
- // 计算手指移动距离,实时更新偏移量
- const deltaX = e.touches[0].clientX - touchStartPoint.value;
- slideOffset.value = initialOffset.value + deltaX;
- }
- /**
- * 处理触摸结束事件
- * 根据滑动距离和速度判断是否切换轮播项
- */
- function onTouchEnd() {
- if (props.list.length <= 1 || !isTouching.value) return;
- touchStartY = 0;
- touchHorizontal = 0;
- // 重置触摸状态
- isTouching.value = false;
- // 恢复动画效果
- isAnimating.value = true;
- // 计算滑动距离、时间和速度
- const deltaX = slideOffset.value - initialOffset.value;
- const deltaTime = Date.now() - touchStartTimestamp.value;
- const velocity = deltaTime > 0 ? Math.abs(deltaX) / deltaTime : 0; // px/ms
- let newIndex = activeIndex.value;
- // 使用当前项的实际宽度进行滑动判断
- const currentSlideWidth = getSlideWidth(activeIndex.value);
- // 判断是否需要切换:滑动距离超过30%或速度够快
- if (Math.abs(deltaX) > currentSlideWidth * 0.3 || velocity > 0.3) {
- // 向右滑动且不是第一项 -> 上一项
- if (deltaX > 0 && activeIndex.value > 0) {
- newIndex = activeIndex.value - 1;
- }
- // 向左滑动且不是最后一项 -> 下一项
- else if (deltaX < 0 && activeIndex.value < props.list.length - 1) {
- newIndex = activeIndex.value + 1;
- }
- }
- // 更新索引 - 如果索引没有变化,需要手动恢复位置
- if (newIndex == activeIndex.value) {
- // 索引未变化,恢复到正确位置
- updateSlidePosition();
- } else {
- // 索引变化,watch会自动调用updateSlidePosition
- activeIndex.value = newIndex;
- }
- // 恢复自动轮播
- setTimeout(() => {
- startAutoplay();
- }, 300);
- }
- /**
- * 处理轮播项点击事件
- * @param index 被点击的轮播项索引
- */
- function handleSlideClick(index: number) {
- emit("item-tap", index);
- }
- /** 监听激活索引变化 */
- watch(activeIndex, (val: number) => {
- updateSlidePosition();
- emit("change", val);
- });
- onMounted(() => {
- getRect();
- startAutoplay();
- });
- // 将触摸事件暴露给父组件,支持控制其它view将做touch代理
- defineExpose({
- onTouchStart,
- onTouchMove,
- onTouchEnd
- });
- </script>
- <style lang="scss" scoped>
- .cl-banner {
- @apply relative z-10 rounded-xl;
- &__list {
- @apply flex flex-row h-full w-full overflow-visible;
- // HBuilderX 4.8.2 bug,临时处理
- width: 100000px;
- transition-property: transform;
- }
- &__item {
- @apply relative duration-200;
- transition-property: transform;
- &-image {
- @apply w-full h-full rounded-xl;
- }
- }
- &__dots {
- @apply flex flex-row items-center justify-center;
- @apply absolute bottom-3 left-0 w-full;
- &-item {
- @apply w-2 h-2 rounded-full mx-1 border border-solid border-surface-500;
- background-color: rgba(255, 255, 255, 0.3);
- transition-property: width, background-color;
- transition-duration: 0.3s;
- &.is-active {
- @apply bg-white w-5;
- }
- }
- }
- }
- </style>
|