核心实现思想
- 控制容器内每一列卡片宽度相同(不同尺寸等比例缩放)
- 第一行卡片紧挨排列,第二行开始,采取贪心思想,每张卡片摆放到当前所有列中高度最小的一列下
后端返回图片尺寸信息问题
- 第一种情况:后端返回数据中包含图片链接、图片宽高信息等。
- 第二种情况:后端返回数据中只包含图片链接,需要获取图片宽高信息,所以需要使用到图片预加载技术。
图片预加载:提前访问链接加载,但不显示在视图上,后续使用链接时从缓存中加载,而不是请求服务器。
图片预加载封装函数如下:
function preLoadImage(link) { //与加载函数,传入链接
return new Promise((resolve, reject) => {
const img = new Image();
img.src = link;
img.onload = () => {
// load 事件代表图片已经加载完毕,通过该回调才访问到图片真正的尺寸信息
resolve({ width: img.width, height: img.height });
};
img.onerror = (err) => {
reject(err);
};
});
}
上述代码中image对象的描述 Javascript中的图像数据对象:Image、ImageData 和 ImageBitmap blog.csdn.net/jimojianghu…
正文
子组件:除js外的组件结构如下
- container作为容器,需要滚动条
- list作为item容器可开启相对定位
- item开启绝对定位,通过translate控制每张卡片位置,定位的统一到左上角
html
<div class="fs-waterfall-container">
<div class="fs-waterfall-list">
<div class="fs-waterfall-item"></div>
</div>
</div>
scss
.fs-waterfall {
&-container {
width: 100%;
height: 100%;
overflow-y: scroll; // 注意需要提前设置展示滚动条,如果等数据展示再出现滚动造成计算偏差
overflow-x: hidden;
}
&-list {
width: 100%;
position: relative;
}
&-item {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
}
}
ts定义类型
export interface IWaterFallProps {
gap: number; // 卡片间隔
column: number; // 瀑布流列数
bottom: number; // 距底距离(触底加载更多)
pageSize: number;
request: (page: number, pageSize: number) => Promise<ICardItem[]>;
}
export interface ICardItem {
id: string | number;
url: string;
width: number;
height: number;
[key: string]: any;
}
// 单个卡片计算的位置信息,设置样式
export interface ICardPos {
width: number;
height: number;
x: number;
y: number;
}
js
定义
const containerRef = ref<HTMLDivElement | null>(null); // 绑定 template 上的 container,需要容器宽度
const state = reactive({
isFinish: false, // 判断是否已经没有数据,后续不再发送请求
page: 1,
cardWidth: 0, // // 容器内卡片宽度
cardList: [] as ICardItem[], // 卡片数据源
cardPos: [] as ICardPos[], // 卡片摆放位置信息
columnHeight: new Array(props.column).fill(0) as number[], // 存储每列的高度,进行初始化操作
});
初始化操作
计算瀑布流布局中卡片宽度
//计算瀑布流布局中卡片宽度
const containerWidth = containerRef.value.clientWidth; //clientWidth不计算滚动条宽度
state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;
封装请求函数
const getCardList = async (page: number, pageSize: number) => {
if (state.isFinish) return;
const list = await props.request(page, pageSize);
state.page++;
if (!list.length) {
state.isFinish = true;
return;
}
state.cardList = [...state.cardList, ...list];
computedCardPos(list); // key:根据请求的数据计算卡片位置
const computedCardPos = (list: ICardItem[]) => { //计算卡片位置函数
list.forEach((item, index) => { //遍历每一项
const cardHeight = Math.floor((item.height * state.cardWidth) / item.width); //计算当前项缩放后的卡片高度
if (index < props.column) { //如果是第一行
state.cardPos.push({
width: state.cardWidth,
height: cardHeight,
x: index ? index * (state.cardWidth + props.gap) : 0,
y: 0,
});
state.columnHeight[index] = cardHeight + props.gap; //高度更新至对应列的state.columnHeight
} else { //如果不是第一行
const { minIndex, minHeight } = minColumn.value; //获取最小高度列信息
state.cardPos.push({
width: state.cardWidth,
height: cardHeight,
x: minIndex ? minIndex * (state.cardWidth + props.gap) : 0,
y: minHeight,
});
state.columnHeight[minIndex] += cardHeight + props.gap;
}
});
};
};
使用
const init = () => {
if (containerRef.value) {
const containerWidth = containerRef.value.clientWidth;
state.cardWidth = (containerWidth - props.gap * (props.column - 1)) / props.column;
getCardList(state.page, props.pageSize);
}
};
onMounted(() => {
init();
});
计算最小列高度
const minColumn = computed(() => {
let minIndex = -1,
minHeight = Infinity;
state.columnHeight.forEach((item, index) => {
if (item < minHeight) {
minHeight = item;
minIndex = index;
}
});
return {
minIndex,
minHeight,
};
});
父组件如下
<template>
<div class="app">
<div class="fs-waterfall-container" ref="containerRef" @scroll="handleScroll">
<fs-waterfall :bottom="20" :column="4" :gap="10" :page-size="20" :request="getData">
<template #item="{ item, index }">
<div
class="card-box"
:style="{
background: colorArr[index % (colorArr.length - 1)],
}"
>
<!-- <img :src="item.url" /> -->
</div>
</template>
</fs-waterfall>
</div>
</div>
</template>
<script setup lang="ts">
import data1 from "./config/data1.json";
import data2 from "./config/data2.json";
import FsWaterfall from "./components/FsWaterfall.vue";
import { ICardItem } from "./components/type";
const colorArr = ["#409eff", "#67c23a", "#e6a23c", "#f56c6c", "#909399"];
const list1: ICardItem[] = data1.data.items.map((i) => ({
id: i.id,
url: i.note_card.cover.url_pre,
width: i.note_card.cover.width,
height: i.note_card.cover.height,
}));
const list2: ICardItem[] = data2.data.items.map((i) => ({
id: i.id,
url: i.note_card.cover.url_pre,
width: i.note_card.cover.width,
height: i.note_card.cover.height,
}));
const list = [...list1, ...list2];
const getData = (page: number, pageSize: number) => {
return new Promise<ICardItem[]>((resolve) => {
setTimeout(() => {
resolve(list.slice((page - 1) * pageSize, (page - 1) * pageSize + pageSize));
}, 1000);
});
};
//触底加载更多
const state = reactive({
// ...
loading: false,
});
const getCardList = async (page: number, pageSize: number) => {
// ...
state.loading = true;
const list = await props.request(page, pageSize);
// ...
state.loading = false;
};
const computedCardPos = (list: ICardItem[]) => {
list.forEach((item, index) => {
// 增加另外条件,cardList <= pageSize 说明是第一次获取数据,第一行紧挨排布
if (index < props.column && state.cardList.length <= props.pageSize) {
// ...
} else {
// ...
}
});
};
const handleScroll = rafThrottle(() => {
const { scrollTop, clientHeight, scrollHeight } = containerRef.value!;
const bottom = scrollHeight - clientHeight - scrollTop;
if (bottom <= props.bottom) {
!state.loading && getCardList(state.page, props.pageSize);
}
});
</script>
<style scoped lang="scss">
.app {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
.container {
width: 700px;
height: 600px;
border: 1px solid red;
}
.card-box {
position: relative;
width: 100%;
height: 100%;
border-radius: 10px;
}
}
</style>