实现弹幕效果,横向循环滚动

82 阅读3分钟

最近接到一个需求要实现下图这样的横向无限滚动展示卡片信息的效果, 先来看看最终效果

image.png

主要有几个细节点:

  1. 每行的滚动速度有差异
  2. 每行的初始位置有一点差异
  3. 当数据少时,至少要滚动一屏后再重复(避免一屏内出现重复)

看了一下组件库里的swiper 组件无法实现这种效果,于是又只能手撸了

首先,将一行的滚动效果封装为一个组件

这个组件需要支持传入数据、初始偏移、滚动速度(每秒滚动多少距离)

type Props = {
    items?: Array<any>;
    initialOffset?: number; // 初始偏移参数
    speed?: number; // 滚动速度参数(每秒移动的距离)
}

第二,内部要重复一组内容,用于滚动无缝衔接

如图所示,第二组是重复组。每组的最小宽度需要等于容器宽度

image-20251108112150121

第三,第一组内容滚动出屏幕时复位

每次滚动到下图(上)所示位置时瞬间恢复到(下)所示位置,就能实现无限循环啦

image-20251108112815442

第四,用帧动画来实现位置移动的动画效果

帧动画核心逻辑

// 动画循环
const animate = () => {
    if (isPaused.value || !contentRef.value) {
        animationFrameId.value = requestAnimationFrame(animate);
        return;
    }
​
    // 更新位置 (每帧移动多少)
    position.value -= rpx2px(props.speed) / 60; // 60fps
​
    // 当第一组内容完全移出屏幕时,重置位置
    if (position.value <= -totalMoveDistance.value) {
        position.value += totalMoveDistance.value;
    }
​
    animationFrameId.value = requestAnimationFrame(animate);
};
​

组件demo

还可以根据需要实现暂停等功能


<script setup lang="ts">
import { rpx2px } from '@/utils';
import { ref, computed, onMounted, nextTick, watch, onUnmounted } from 'vue';
import { ShiningPointsType } from '@/constants/shining-points';
​
const props = withDefaults(defineProps<{
    items?: Array<any>;
    initialOffset?: number; // 初始偏移参数
    speed?: number; // 滚动速度参数(每秒移动的距离)
}>(), {
    items: () => [],
    speed: 60, // 60rpx/秒
});
​
const isPaused = ref(false);
const contentRef = ref<HTMLElement | null>(null);
const firstGroupRef = ref<HTMLElement | null>(null);
const groupWidth = ref(0);
const containerWidth = ref(0);
const groupGap = 20;
​
const animationFrameId = ref<number | null>(null);
const position = ref(0);
const totalMoveDistance = computed(() => {
    return groupWidth.value + rpx2px(groupGap);
});
​
const contentStyle = computed(() => {
    return {
        transform: `translateX(${position.value}px)`,
    }
});
​
// 计算内容宽度
const calculateWidths = async () => {
    await nextTick();
    if (contentRef.value) {
        const container = contentRef.value.parentElement;
        const group = firstGroupRef.value;
        if (container && group) {
            containerWidth.value = container.offsetWidth;
​
            // 获取内容组的宽度,如果内容很少则使用容器宽度
            const contentWidth = group.scrollWidth;
            groupWidth.value = Math.max(contentWidth, containerWidth.value)
​
            position.value = containerWidth.value + rpx2px(props.initialOffset || 0);
        }
    }
};
​
// 动画循环
const animate = () => {
    if (isPaused.value || !contentRef.value) {
        animationFrameId.value = requestAnimationFrame(animate);
        return;
    }
​
    // 更新位置 (每帧移动多少)
    position.value -= rpx2px(props.speed) / 60; // 60fps
​
    // 当第一组内容完全移出屏幕时,重置位置
    if (position.value <= -totalMoveDistance.value) {
        console.log('循环滚动到初始位置');
        position.value += totalMoveDistance.value;
    }
​
    animationFrameId.value = requestAnimationFrame(animate);
};
​
// 开始动画
const startAnimation = () => {
    if (animationFrameId.value) {
        cancelAnimationFrame(animationFrameId.value);
    }
    animate();
};
​
​
watch(() => props.items, async (newItems) => {
    console.log('watch', newItems);
    await calculateWidths()
    startAnimation();
})
​
​
onMounted(async () => {
    await calculateWidths();
    startAnimation();
​
    // 监听窗口大小变化
    window.addEventListener('resize', calculateWidths);
});
​
onUnmounted(() => {
    if (animationFrameId.value) {
        cancelAnimationFrame(animationFrameId.value);
    }
    window.removeEventListener('resize', calculateWidths);
});
</script><template>
    <div class="relative w-full overflow-hidden">
        <div class="flex" ref="contentRef" :style="contentStyle">
            <div ref="firstGroupRef" class="flex gap-20rpx min-w-full flex-shrink-0">
                <div v-for="(item, index) in items" :key="`1-${index}`" :class="['scroll-item', {
                    'item-auth-success': !!item.idName,
                    'item-in-campus': item.categoryInfo.type === ShiningPointsType.InCampus,
                    'item-skill': item.categoryInfo.type === ShiningPointsType.Skill,
                    'item-specialty': item.categoryInfo.type === ShiningPointsType.Specialty,
                    'item-out-campus': item.categoryInfo.type === ShiningPointsType.OutCampus,
                    'item-experience': item.categoryInfo.type === ShiningPointsType.Experience,
                }]">
                    <template v-if="item.idName">
                        <van-image class="w-22rpx h-22rpx rounded-full overflow-hidden" :src="item.headImg" />
                        <span>{{ item.idName }}</span>
                        <span class="text-[#ffffff99]">认证成功</span>
                    </template>
                    <van-image v-else class="w-22rpx h-22rpx rounded-full overflow-hidden"
                        :src="item.categoryInfo.iconUrl" />
                    <span>{{ item.flashPointName }}</span>
                </div>
            </div>
            <div class="flex-shrink-0" :style="{ width: rpx2px(groupGap) + 'px' }"/>
            <div class="flex gap-20rpx min-w-full flex-shrink-0">
                <div v-for="(item, index) in items" :key="`2-${index}`" :class="['scroll-item', {
                    'item-auth-success': !!item.idName,
                    'item-in-campus': item.categoryInfo.type === ShiningPointsType.InCampus,
                    'item-skill': item.categoryInfo.type === ShiningPointsType.Skill,
                    'item-specialty': item.categoryInfo.type === ShiningPointsType.Specialty,
                    'item-out-campus': item.categoryInfo.type === ShiningPointsType.OutCampus,
                    'item-experience': item.categoryInfo.type === ShiningPointsType.Experience,
                }]">
                    <template v-if="item.idName">
                        <van-image class="w-22rpx h-22rpx rounded-full overflow-hidden" :src="item.headImg" />
                        <span>{{ item.idName }}</span>
                        <span class="text-[#ffffff99]">认证成功</span>
                    </template>
                    <van-image v-else class="w-22rpx h-22rpx rounded-full overflow-hidden"
                        :src="item.categoryInfo.iconUrl" />
                    <span>{{ item.flashPointName }}</span>
                </div>
            </div>
        </div>
    </div>
</template><style scoped>
.scroll-item {
    display: inline-flex;
    gap: 6rpx;
    align-items: center;
    height: 36rpx;
    box-sizing: border-box;
    color: #fff;
    font-size: 12rpx;
    line-height: 12rpx;
    border-radius: 99rpx;
    position: relative;
    flex-shrink: 0;
    padding: 7rpx 15rpx 7rpx 6rpx;
​
​
    &::before {
        content: '';
        position: absolute;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        border-radius: 99rpx;
        padding: 1.2rpx;
        background: linear-gradient(120deg, rgba(242, 239, 250, 0.6), rgba(242, 239, 250, 0.05));
        -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
        -webkit-mask-composite: xor;
        mask-composite: exclude;
        pointer-events: none;
    }
​
    &.item-auth-success {
        background: linear-gradient(90deg, rgba(149, 69, 255, 0.72) 0%, rgba(123, 90, 255, 0.49) 100%) !important;
        box-shadow: -1px -1px 1px 0 rgba(120, 77, 249, 0.44) inset;
​
        &::before {
            background: linear-gradient(120deg, rgba(255, 255, 255, 0.8), rgba(242, 239, 250, 0.05));
        }
    }
​
    &.item-in-campus {
        background: linear-gradient(90deg, rgba(47, 144, 255, 0.25) 0%, rgba(28, 229, 159, 0.10) 100%);
    }
​
    &.item-skill,
    &.item-specialty {
        background: linear-gradient(90deg, rgba(150, 115, 255, 0.20) 0.96%, rgba(190, 178, 223, 0.05) 100%);
    }
​
    &.item-out-campus,
    &.item-experience {
        background: linear-gradient(90deg, rgba(253, 42, 246, 0.25) 0%, rgba(128, 89, 255, 0.10) 100%);
    }
}
</style>

最后,调用组件

<script setup lang="ts">
const renderList = [
    [
        {
            "categoryInfo": {
                "type": 1
            },
            "idName": "邓大福",
            "headImg": "https://static-legacy.dingtalk.com/media/lADPD3Irse4l4lnNAlnNAlk_601_601.jpg",
            "flashPointName": "酒吧驻唱"
        },
        {
            "categoryInfo": {
                "type": 1
            },
            "flashPointName": "英语六级高分"
        },
        {
            "categoryInfo": {
                "type": 5
            },
            "flashPointName": "富二代"
        },
        {
            "categoryInfo": {
                "type": 4
            },
            "flashPointName": "红学家"
        }
    ],
    [
        {
            "categoryInfo": {
                "type": 2
            },
            "idName": "王小帅",
            "headImg": "https://static-legacy.dingtalk.com/media/lADPD3Irse4l4lnNAlnNAlk_601_601.jpg",
            "flashPointName": "滨江区第一鲁班"
        },
        {
            "categoryInfo": {
                "type": 2
            },
            "flashPointName": "学生会主席"
        },
        {
            "categoryInfo": {
                "type": 2
            },
            "flashPointName": "钻石王老五"
        }
    ],
    [
        {
            "categoryInfo": {
                "type": 2
            },
            "idName": "杰西卡",
            "headImg": "https://static-legacy.dingtalk.com/media/lADPD3Irse4l4lnNAlnNAlk_601_601.jpg",
            "flashPointName": "上城破风王"
        },
        {
            "categoryInfo": {
                "type": 3
            },
            "flashPointName": "国家二级运动员"
        },
        {
            "categoryInfo": {
                "type": 2
            },
            "flashPointName": "钻石王老六"
        }
    ],
    [
        {
            "categoryInfo": {
                "type": 2
            },
            "idName": "百里",
            "headImg": "https://static-legacy.dingtalk.com/media/lADPD3Irse4l4lnNAlnNAlk_601_601.jpg",
            "flashPointName": "第一百里守约"
        },
        {
            "categoryInfo": {
                "type": 4
            },
            "flashPointName": "大学生创业者"
        },
        {
            "categoryInfo": {
                "type": 4
            },
            "flashPointName": "荒野老六"
        }
    ],
    [
        {
            "categoryInfo": {
                "type": 5
            },
            "flashPointName": "10w粉丝博主"
        },
        {
            "categoryInfo": {
                "type": 4
            },
            "flashPointName": "林大彪"
        }
    ]
]
</script>
​
​
<template>
  <div class="flex min-h-355rpx flex-1 pt-37rpx flex-col gap-20rpx">
              <HorizontalScroller v-if="size(renderList[0])" :items="renderList[0]" :initial-offset="0" :speed="55" />
              <HorizontalScroller v-if="size(renderList[1])" :items="renderList[1]" :initial-offset="20" :speed="60" />
              <HorizontalScroller v-if="size(renderList[2])" :items="renderList[2]" :initial-offset="30" :speed="65" />
              <HorizontalScroller v-if="size(renderList[3])" :items="renderList[3]" :initial-offset="60" :speed="60" />
              <HorizontalScroller v-if="size(renderList[4])" :items="renderList[4]" :initial-offset="10" :speed="55" />
          </div>
</template>

​​