Vue
实现列表自动滚动(纯与原生方式)
源码放在最后!
1.效果展示:
2.功能说明:
该滚动可能存在的Bug:
- 1.如果你写的大屏不是使用的接口轮询的方式可能会存在也页面空白的情况(需要手动刷新才能触发列表滚动),因为我使用的是监听数据的变化然后做出响应,一般来说写大屏都会有个数据更新的机制。这一点需要你对源码进行改动。
3.原理分析:
1.核心数据和变量:
-
CityData
:包含了所有的表格数据,是一个二维数组,每一行数据代表一个城市的信息。 -
CURRENTDATA
:存储当前显示在屏幕上的行数据,动态更新以实现滚动效果。 -
COLUMNHEIGHT
:存储每一行的高度,在滚动时通过调整前几行的高度来实现滑动效果。 -
CURRENTINDEX
:表示当前显示的首行索引,在滚动过程中不断递增,最终通过循环回到起始行。
2.数据初始化:
当组件挂载时,会通过 onMounted
钩子初始化表格数据。关键步骤包括:
- 计算每列的宽度
HandelHeaderAverageWidth
和HandelColumnAverageWidth
。 - 计算行的高度
HandelAverageHeight
。 - 格式化数据并初始化
CityData
,为每一行数据添加索引列,并根据Alluserdata
动态填充内容。
3.高度变化实现(核心!):
滚动动画的关键在于每行的高度如何变化。为了模拟滚动,我们让行逐渐“消失”并再“出现”。具体实现步骤如下:
-
初始行高度:表格中的每一行都有固定的高度,在初始化时通过
HandelAverageHeight
计算每行的平均高度,并将结果存储在COLUMNHEIGHT
中。const HandelAverageHeight = () => { const AverageHeight = height.value / columnNumber.value; AverageHeighT.value = AverageHeight; const TotalCount = 23; // 表示总的行数 COLUMNHEIGHT.value = new Array(TotalCount).fill(AverageHeight); };
例如,假设每行的高度为
50px
,这意味着表格中的所有行在初始时都是统一高度的。 -
高度变化模拟滚动:在开始动画时,
StartAnimation
函数负责将某些行的高度设置为0
,模拟这些行滚动出可视区域。COLUMNHEIGHT.value.splice(0, MoveNumber, ...new Array(MoveNumber).fill(0));
这个
splice
操作将COLUMNHEIGHT
数组中前MoveNumber
行的高度设置为0
,因此这些行会逐渐缩小到不可见,产生滚动的效果。当行的高度变为
0
时,浏览器会根据 CSS 的transition
动画特性让该行逐渐收缩,这就是为什么行从视野中“滑动”出去,而不是瞬间消失。
4.数据无限循环的过程:
为了让滚动效果无限循环,需要对 CityData
(存储所有表格数据)和 CURRENTDATA
(存储当前显示的行数据)进行操作,使得数据能够循环显示。
-
当前索引控制:通过
CURRENTINDEX
控制当前显示的起始行。在每次动画开始时,会计算新的起始行索引,并根据这个索引来更新当前显示的数据。const alldataLenght = CityData.value.length; // 获取所有数据的长度 const MoveNumber = 1; // 表示每次滚动多少行 const index = CURRENTINDEX.value; // 当前滚动的索引
CURRENTINDEX
会随着每次滚动递增,当它达到数据集的长度时,循环回到开头,确保表格数据可以无限滚动。 -
数据循环拼接:为了避免滚动到最后几行时的“断层”,
StartAnimation
函数会将CityData
从当前索引开始的数据提取出来,并将前面的数据拼接到后面,形成一个无缝连接的效果。const rowdata = cloneDeep(CityData.value); // 深拷贝 CityData 避免直接修改 const rows = rowdata.slice(index); // 从当前索引开始提取数据 rows.push(...rowdata.slice(0, index)); // 将索引之前的行拼接到后面
比如当表格已经滚动到倒数第二行时,
slice(index)
提取出剩下的最后几行,而slice(0, index)
则从头部提取最开始的几行,并将它们拼接在一起。这使得滚动能够无缝连接。这样,在
CURRENTDATA
中,数据始终是连续的,模拟了数据无限循环滚动的效果。
5.递归定时器控制滚动:
滚动效果通过递归调用 StartAnimation
来实现,每次滚动后等待一段时间,然后再执行下一次滚动。这就像一个“无尽的循环”,使表格持续滚动。
-
延迟机制:在每次滚动之前,会通过
await
等待一段时间,控制滚动的速度。await new Promise(resolve => setTimeout(resolve, awaitTime)); // 等待500ms
这里的
awaitTime
控制了滚动的速度,500ms 表示每次滚动间隔 0.5 秒。 -
高度动画延迟:滚动分为两个阶段,先将行的高度逐渐设置为
0
,然后让下一行进入视野。这个过程通过两次setTimeout
来控制。await new Promise(resolve => setTimeout(resolve, 800 - awaitTime)); // 额外等待800ms
这个延迟的机制让滚动变得平滑,防止表格中的数据瞬间跳跃。
4.完整源码
首先你要有pinia、loadsh、uuid这些库
这是我的文件目录,有点不规范,你可以自己修改hook可以抽取一下!
PlanList.vue
<template>
<div class="AutoScrollTabel" :id="ID">
<div class="header">
<template v-for="(item, index) in Headerdata">
<div class="headeritem" :style="{ width: `${AVERAGEWIDTH[index]}px` }">
{{ item }}
</div>
</template>
</div>
<div class="ColumLIst">
<template v-for="(item, index) in CURRENTDATA" :key="item.Rowindex + '-' + index">
<div class="itemWrapper"
:style="{ height: `${COLUMNHEIGHT[index]}px`, lineHeight: `${COLUMNHEIGHT[index]}px`, backgroundColor: item.Rowindex % 2 === 0 ? BGCOLOR2 : BGCOLOR1 }">
<template v-for="(iten, idx) in item.data" :key="item.Rowindex + '-' + idx">
<!-- 使用 Rowindex 和 idx 组合,确保每一项唯一 -->
<div class="item" :align="align[idx]" :style="{ width: `${COLUMNAVERAGEWIDTH[idx]}px` }"
v-html="iten"></div>
</template>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, watch, nextTick } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import cloneDeep from 'loadsh/cloneDeep'
import { storeToRefs } from 'pinia' // pinia
import { useAllUserDataStore } from '../../../store/index'; // pinia
const AllUserData = useAllUserDataStore() // pinia
const { Alluserdata } = storeToRefs(AllUserData) // pinia
import init from '../utils/init'
// CityData: 用于存储城市数据的二维数组,每行代表一条记录,每列代表不同的数据项
const CityData = ref([])
// columnIndex: 存储每行数据的索引列,通常用来表示行号或某种标识符
const columnIndex = ref([])
// Headerdata: 存储表格的表头数据,代表每列的标题
const Headerdata = ["城市订单量", "店铺数", "接单骑手人数", "新店铺数量", "人均订单量"]
// AVERAGEWIDTH: 存储表头每列的平均宽度
const AVERAGEWIDTH = ref([])
// COLUMNAVERAGEWIDTH: 存储每列的平均宽度,除了第一列的额外宽度(通常用于索引列)
const COLUMNAVERAGEWIDTH = ref([])
// columnNumber: 表示每列的数量或展示的列数,可能会影响表格的布局
const columnNumber = ref(10)
// AverageHeighT: 存储每行的平均高度,用于控制行高
const AverageHeighT = ref(0)
// COLUMNHEIGHT: 存储每行的高度,用于控制行的高度
const COLUMNHEIGHT = ref([])
// Datalenght: 存储数据的长度,即行数
const Datalenght = ref(0)
// CURRENTINDEX: 当前滚动到的索引位置,用于动画或分页等功能
const CURRENTINDEX = ref(0)
// CURRENTDATA: 存储当前显示的数据,用于表格的内容展示
const CURRENTDATA = ref([])
// ID: 每个表格的唯一标识符,使用 UUID 来确保唯一性
const ID = `BaseScorelist+${uuidv4()}`
// BGCOLOR1: 表格背景颜色 1,用于奇数行或默认背景色
const BGCOLOR1 = 'rgb(55,55,55)'
// BGCOLOR2: 表格背景颜色 2,用于偶数行或替代背景色
const BGCOLOR2 = 'rgb(44,44,44)'
// align: 表格每列的对齐方式,这里是全部居中
const align = ["center", "center", "center", "center", "center", "center"]
const { width, height } = init(ID)
// 生成 UUID 的函数
function generateUUID() {
return uuidv4();
}
// 处理数据行,将数据转换为适合显示的格式
const Handeldata = () => {
// 为每一行数据添加行索引
CityData.value.map((item, index) => {
item.unshift(columnIndex.value[index])
})
// 生成包含行数据和行索引的数组
const data = CityData.value.map((item, index) => {
return {
data: item,
Rowindex: index
}
})
// 更新 CityData
CityData.value = data
}
// 处理表头的宽度
const HandelHeaderAverageWidth = () => {
const needAverage = Headerdata.length
const currentWidth = 50
// 计算表头的平均宽度
const averagewidth = (width.value - currentWidth) / needAverage
AVERAGEWIDTH.value = new Array(Headerdata.length)
AVERAGEWIDTH.value.fill(averagewidth)
}
// 处理表格列的平均宽度(除了索引列)
const HandelColumnAverageWidth = () => {
let needAverage = 5
const averagewidth = (width.value - 40) / needAverage
COLUMNAVERAGEWIDTH.value = new Array(5)
COLUMNAVERAGEWIDTH.value.fill(averagewidth)
COLUMNAVERAGEWIDTH.value.unshift(50)
}
// 处理每行的平均高度
const HandelAverageHeight = () => {
const AverageHeight = height.value / columnNumber.value
AverageHeighT.value = AverageHeight
const TotalCount = 23
COLUMNHEIGHT.value = new Array(TotalCount).fill(AverageHeight)
}
// 启动动画,控制表格内容的滚动
const StartAnimation = async () => {
console.log("111")
// 获取真实数据长度
const alldataLenght = CityData.value.length;
const MoveNumber = 1;
const index = CURRENTINDEX.value;
// 当数据长度小于需要移动的数量时,直接退出
// if (alldataLenght <= MoveNumber) return;
console.log("进入了动画函数 StartAnimation")
const rowdata = cloneDeep(CityData.value);
const rows = rowdata.slice(index); // 获取当前起始位置后的数据
// 连接数据头尾,避免卡顿
rows.push(...rowdata.slice(0, index));
CURRENTDATA.value = rows;
COLUMNHEIGHT.value = new Array(alldataLenght).fill(AverageHeighT.value);
const awaitTime = 500;
await new Promise(resolve => setTimeout(resolve, awaitTime));
// 更新前几个行的高度为 0
COLUMNHEIGHT.value.splice(0, MoveNumber, ...new Array(MoveNumber).fill(0));
// 更新当前索引
CURRENTINDEX.value += MoveNumber;
const ISLAST = CURRENTINDEX.value - alldataLenght;
if (ISLAST >= 0) {
CURRENTINDEX.value = ISLAST; // 循环回到数据开始位置
}
await new Promise(resolve => setTimeout(resolve, 800 - awaitTime));
await StartAnimation(); // 递归调用自身进行动画
console.log("调用了动画函数 StartAnimation");
};
watch(() => Alluserdata.value, (NewData) => {
Datalenght.value = NewData.length;
CityData.value = []; // 清空原有数据
for (let i = 0; i < Datalenght.value; i++) {
CityData.value[i] = [];
// 生成列索引和内容
if (i % 2 === 0) {
columnIndex.value[i] = `<div style="width:100%; padding-left=5px; height:100%; display:flex; align-items:center; justify-content:center; background:rgb(44,44,44)">
<div style="width:15px;height:15px;background:rgb(72,122,72);border-radius:50%;border:1px solid #fff;"/>
</div>`;
} else {
columnIndex.value[i] = `<div style="width:100%; height:100%; display:flex; align-items:center; justify-content:center; background:rgb(44,44,44)">
<div style="width:15px;height:15px;background:rgb(38,88,104);border-radius:50%;border:1px solid #fff;"/>
</div>`;
}
// 生成数据行
for (let i = 0; i < Datalenght.value; i++) {
CityData.value[i] = [];
// 根据行索引来决定前面点的背景颜色
const pointBgColor = i % 2 === 0 ? 'rgb(44,44,44)' : 'rgb(55,55,55)'; // 偶数行和奇数行的背景颜色
columnIndex.value[i] = `
<div style="width:100%; height:100%; display:flex; align-items:center; justify-content:center; background:${pointBgColor}">
<div style="width:15px;height:15px;background:rgb(72,122,72);border-radius:50%;border:1px solid #fff;"></div>
</div>`;
// 生成数据行
for (let j = 0; j < 5; j++) {
let text = '';
switch (j) {
case 0: text = NewData[i].order; break;
case 1: text = NewData[i].shop; break;
case 2: text = NewData[i].rider; break;
case 3: text = NewData[i].newShop; break;
case 4: text = NewData[i].avgOrder; break;
}
// 其他列的内容颜色保持不变
if (j === 1 || j === 3) {
CityData.value[i].push(`<div style="color:rgb(178,209,126)">${text}</div>`);
} else {
CityData.value[i].push(`<div>${text}</div>`);
}
}
}
}
Handeldata();
});
onMounted(() => {
StartAnimation()
Handeldata()
HandelHeaderAverageWidth()
HandelColumnAverageWidth()
HandelAverageHeight()
})
</script>
<style scoped lang="less">
.AutoScrollTabel {
width: 100%;
height: 100%;
// margin: 30px;
user-select: none;
box-sizing: border-box;
.header {
display: flex;
align-items: center;
// justify-content: space-around;
background-color: rgb(90, 90, 90);
color: rgb(255, 255, 255);
padding-left: 50px;
.headeritem {
font-size: 15px;
padding: 10px 0;
text-align: center;
}
}
.ColumLIst {
.itemWrapper {
overflow: hidden;
display: flex;
font-size: 30px;
align-items: center;
transition: all 0.3s linear;
.item {
width: 100%;
height: 100%;
overflow: hidden;
text-align: center;
// align-items: center;
font-size: 20px;
// padding: 10px 0px;
}
}
}
}
</style>
init.ts
import { ref, onMounted } from 'vue'
const init = (id: string) => {
const width = ref<number>(0)
const height = ref<number>(0)
// 获取 dom
onMounted(() => {
const container = document.getElementById(id) as HTMLElement | null
if (container) {
const domwidth = container.clientWidth
const domheight = container.clientHeight
// 初始化宽高
width.value = domwidth
height.value = domheight
}
})
return {
width,
height
}
}
export default init
PanList文件夹下的Index.vue
<template>
<div class="plan-list">
<div class="title">区域销售大盘环比分析</div>
<div class="planlistScore">
<PanlList />
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import PanlList from './cpns/PanlList.vue';
import { useAllUserDataStore } from '../../store/index'; // 引入 Pinia store
// 获取 Pinia store 实例
const AllUserDataStore = useAllUserDataStore();
// 定义更新数据函数
const updateData = () => {
// 直接触发更新,而不是生成随机数据
const newData = AllUserDataStore.getAllUserData(); // 假设这个函数获取当前的用户数据
AllUserDataStore.setAllUserData(newData); // 直接更新数据
};
// 使用 onMounted 生命周期钩子来定时更新数据
onMounted(() => {
// 每隔 3 秒触发一次更新
setInterval(() => {
updateData();
}, 1000); // 每 3 秒更新一次
});
</script>
<style scoped lang="less">
.plan-list {
width: 100%;
height: 100%;
background: rgb(55, 55, 55);
padding: 20px 10px;
color: #fff;
box-sizing: border-box;
.title {
font-size: 26px;
text-align: center;
}
.planlistScore {
width: 100%;
height: calc(100% - 40px);
background: rgb(40, 40, 40);
overflow: hidden;
}
}
</style>
Store文件夹下面的index.ts
import { defineStore } from 'pinia'
// 定义 `UserData` 数据结构类型
interface UserData {
order: string; // 修改为 string 类型
shop: string; // 修改为 string 类型
rider: string; // 修改为 string 类型
newShop: string; // 修改为 string 类型
avgOrder: string; // 修改为 string 类型
Rowindex?: number; // 添加 RowIndex 字段
}
// 定义 store
export const useAllUserDataStore = defineStore('AllUserData', {
state: (): { Alluserdata: UserData[] } => ({
Alluserdata: [
{ "order": "北京 -17%", "shop": "北京 -3%", "rider": "北京 +20%", "newShop": "北京 +14%", "avgOrder": "北京 -16%" },
{ "order": "上海 -19%", "shop": "上海 +12%", "rider": "上海 -15%", "newShop": "上海 +15%", "avgOrder": "上海 -21%" },
{ "order": "广州 +20%", "shop": "广州 -5%", "rider": "广州 +17%", "newShop": "广州 -15%", "avgOrder": "广州 +10%" },
{ "order": "深圳 -4%", "shop": "深圳 -23%", "rider": "深圳 -5%", "newShop": "深圳 +15%", "avgOrder": "深圳 -4%" },
{ "order": "南京 +10%", "shop": "南京 -6%", "rider": "南京 -7%", "newShop": "南京 -5%", "avgOrder": "南京 -22%" },
{ "order": "杭州 -3%", "shop": "杭州 +20%", "rider": "杭州 +16%", "newShop": "杭州 -11%", "avgOrder": "杭州 -17%" },
{ "order": "合肥 +8%", "shop": "合肥 +17%", "rider": "合肥 -23%", "newShop": "合肥 +6%", "avgOrder": "合肥 -11%" },
{ "order": "济南 -13%", "shop": "济南 -11%", "rider": "济南 +6%", "newShop": "济南 -7%", "avgOrder": "济南 +18%" },
{ "order": "太原 -11%", "shop": "太原 -10%", "rider": "太原 +18%", "newShop": "太原 -14%", "avgOrder": "太原 -2%" },
{ "order": "成都 +19%", "shop": "成都 -6%", "rider": "成都 +14%", "newShop": "成都 -19%", "avgOrder": "成都 +10%" },
{ "order": "重庆 -12%", "shop": "重庆 +12%", "rider": "重庆 +12%", "newShop": "重庆 +7%", "avgOrder": "重庆 -3%" },
{ "order": "苏州 +15%", "shop": "苏州 +18%", "rider": "苏州 -8%", "newShop": "苏州 -3%", "avgOrder": "苏州 +4%" },
{ "order": "无锡 -15%", "shop": "无锡 -19%", "rider": "无锡 -14%", "newShop": "无锡 +21%", "avgOrder": "无锡 -21%" },
{ "order": "常州 +12%", "shop": "常州 +10%", "rider": "常州 +5%", "newShop": "常州 -22%", "avgOrder": "常州 -13%" },
{ "order": "温州 -14%", "shop": "温州 -16%", "rider": "温州 -15%", "newShop": "温州 +14%", "avgOrder": "温州 +17%" },
{ "order": "哈尔滨 +5%", "shop": "哈尔滨 -18%", "rider": "哈尔滨 -18%", "newShop": "哈尔滨 -18%", "avgOrder": "哈尔滨 -20%" },
{ "order": "长春 +14%", "shop": "长春 -5%", "rider": "长春 -17%", "newShop": "长春 +18%", "avgOrder": "长春 +4%" },
{ "order": "大连 -4%", "shop": "大连 -15%", "rider": "大连 -22%", "newShop": "大连 +14%", "avgOrder": "大连 +14%" },
{ "order": "沈阳 -1%", "shop": "沈阳 -21%", "rider": "沈阳 -15%", "newShop": "沈阳 +24%", "avgOrder": "沈阳 +8%" },
{ "order": "拉萨 +18%", "shop": "拉萨 +5%", "rider": "拉萨 -6%", "newShop": "拉萨 -24%", "avgOrder": "拉萨 -10%" },
{ "order": "呼和浩特 +14%", "shop": "呼和浩特 -12%", "rider": "呼和浩特 +8%", "newShop": "呼和浩特 +9%", "avgOrder": "呼和浩特 -21%" },
{ "order": "武汉 -21%", "shop": "武汉 +13%", "rider": "武汉 -10%", "newShop": "武汉 -14%", "avgOrder": "武汉 +10%" },
{ "order": "南宁 -22%", "shop": "南宁 +23%", "rider": "南宁 -9%", "newShop": "南宁 +6%", "avgOrder": "南宁 -12%" }
] // 初始数据
}),
actions: {
// 更新数据,并保持 RowIndex 一致
setAllUserData(newData: UserData[]) {
// 更新数据时可以保持 RowIndex
this.Alluserdata = newData.map((item, index) => ({
...item,
Rowindex: index + 1 // 给每一行加上递增的 RowIndex,从 1 开始
}));
console.log("Store updated");
},
// 添加新数据
addData(data: UserData) {
const newRowIndex = this.Alluserdata.length ? Math.max(...this.Alluserdata.map(item => item.Rowindex!)) + 1 : 1;
this.Alluserdata.push({ ...data, Rowindex: newRowIndex });
},
getAllUserData() {
return this.Alluserdata;
}
}
});
App.vue
<template>
<div class="Wrapper">
<div class="PanListWrapper">
<PanList></PanList>
</div>
</div>
</template>
<script setup lang="ts">
import PanList from './components/PanList/index.vue'
</script>
<style scoped lang="less">
.Wrapper {
width: 100%;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.PanListWrapper {
width: 700px;
height: 700px;
}
}
</style>