1. 瀑布流布局的定义
页面上给人一种参差不齐的多栏布局,其中元素大部分为图片,图片的宽度是统一固定的,但是由于高度不一样,第一行图片排满之后,新的图片会插入到第一排中高度最低的图片下面,并更新高度,如此循环,最终达到瀑布流式的效果。下面看两个瀑布流布局的常见例子:
小红书:
淘宝M端:
除了小红书和淘宝,很多的产品和网站都会用到瀑布流布局,利用瀑布流布局能够有效的吸引用户的眼球、有效的利用空间,作为前端开发者,都应该掌握瀑布流布局的实现思路。
其实实现瀑布流布局的方法非常多,使用纯 CSS 或者使用 JS 都能实现,我们看下具体的实现方案!
2. 具体的实现方案
column-count
column-count 表示分栏数目,column-gap 表示分栏的间距;这里表示分 2 栏,两栏之间的间距为 30px
.container {
padding: 10px;
column-count: 3;
column-gap: 30px;
}
这种方法存在一些缺点:排列顺序是先上下后左右,而用户是横排观看,因此无法将优先级较高的项排列在前。无限滚动时由于新元素的加入导致,第二列上面的元素会移动到左边一列的最下面导致出现抖动。
gird布局
这种方式也可以实现,而且不会出现 column-count 布局以及 flex 布局的缺点,由于我对 gird 布局不是很了解,因此这里不提供具体的讲解思路,有兴趣的小伙伴可以自行查阅相关的资料。
flex布局
将容器设置为 flex 布局,设置允许换行 + 平均分布所有项目之间的间距。
.container {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
}
缺点:由于 flex 布局是一行一行的,无法实现卡片的等宽不等高,导致每行上下卡片会出现空隙
定位布局
计算每个 card 的高度,使用 absolute 定位动态计算 card 高度的偏移位置,这是一些商业瀑布流的常见做法。缺点:计算代价大,可能会造成大量重排。
这里着重讲解使用 JS + absolute 定位实现瀑布流布局!
3. 动态计算+定位实现
先看下最终的效果:
我这里使用 Vue3 实现该功能,主要可以分为三个步骤:
- 创建图片元素并加入页面中
- 根据容器的宽度计算有多少列,以及间隙的数量
- 设置每张图片的位置(关键)
- 监听窗口 resize 事件,重新设置每张图片的位置
首先定义好图片的宽度以及间隙的宽度,瀑布流布局一定是要固定图片的宽度的,高度不相等
const imgWidth = 200; // 每张图片的宽度
const spaceWidth = 30; // 水平间隙的宽度
const spaceHeight = 30; // 垂直间隙的高度
创建图片元素加入页面
使用 glob 批量导入指定文件夹下的图片,如果是在真实项目的话,图片可能是由接口返回;不同的处理方式代码会不一样,所以下面的代码仅供参考。
// 导入所有的图片
let imageObj = import.meta.glob('@/assets/img/*.*', { eager: true });
let imageList = Object.values(imageObj).map((v: any) => v.default);
给每张图片都设置为绝对定位,这里有一个关键点:在图片加载完成之后(通过 onload 事件判断),调用 setPositions 方法重置每张图片的位置,不然整个页面的布局会很混乱,达不到想要的效果。
const createImgs = () => {
for (let i = 0; i < imageList.length; i++) {
let img = document.createElement('img');
img.src = imageList[i];
img.onload = setPositions;
img.style.position = 'absolute';
img.style.width = imgWidth + 'px';
img.style.transition = '0.3s';
ImgContainer.value.appendChild(img);
}
};
计算列数和间隙
外部容器的宽度不是固定的,采用百分比布局:
.container {
position: relative;
width: 60%;
margin: 50px auto;
}
首先通过 clientWidth 拿到容器的宽度,同时计算出有多少列以及间隙的数量,然后再重新计算容器的宽度并修改,防止容器出现多余的间隙,影响美观。
// 计算一共有多少列,间隙的数量
const cal = () => {
let containerWidth = ImgContainer.value.clientWidth; // 容器的宽度
let columns = Math.floor(containerWidth / imgWidth); // 计算列数
let spaceNumber = columns - 1; // 间隙数量
ImgContainer.value.style.width =
columns * imgWidth + spaceNumber * spaceWidth + 'px'; // 重置容器的宽度
return {
sapce: spaceWidth,
columns,
};
};
设置每张图片的位置
这是最关键的一步,核心:每一轮找到高度最小的列,添加图片
准备好一个数组,其元素个数等于列数,初始元素的值均为0,例如 nextTops[i] = j 表示第i + 1列下一张图片的纵坐标是 j,下面通过几张图来详细分析:
初始状态:
根据每列图片的高度以及间隙的高度(这里是30),更新 nextTops 数组中的元素:
开启下一轮,找出 nextTops 中的最小值,也就是第一列,因此第四张图片放到第一列:
再次更新 nextTops 数组中的元素,然后以此类推就可以完成整个页面的图片布局啦!
// 设置每张图片的位置
const setPositions = () => {
ImgContainer.value.style.width = '60%'; // 重置容器的宽度
let info = cal(); // 得到列数和间隙空间
let nextTops = new Array(info.columns); // 该数组的长度为列数,每一项表示该列的下一个图片的纵坐标
nextTops.fill(0); // 数组每一项填充为0
for (let i = 0; i < ImgContainer.value.children.length; i++) {
let img = ImgContainer.value.children[i];
// 找到nextTops中的最小值作为当前图片的纵坐标
let minTop = Math.min.apply(null, nextTops);
img.style.top = minTop + 'px';
// 重新设置数组这一项的下一个top值
let index = nextTops.indexOf(minTop); // 得到使用的是第几列的top值
nextTops[index] += img.height + spaceHeight;
let left = index * info.sapce + index * imgWidth;
img.style.left = left + 'px';
}
let max = Math.max.apply(null, nextTops); // 求最大值
ImgContainer.value.style.height = max + 'px'; // 3. 设置容器的高度
};
监听窗口大小改变
当窗口大小改变后,容器的宽度会改变,重新设置每张图片的位置即可,同时由于 resize 事件频繁触发会浪费性能,因此采用防抖的方式优化。
const headleResize = _.debounce(setPositions, 300);
onMounted(() => {
createImgs();
window.addEventListener('resize', headleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', headleResize);
});
到这里,基本就完成瀑布流布局了,当然这里只是提供一种思路,具体的实现细节还要根据不同的项目需求去做一些处理,还有很多小问题是需要处理的,以下是完整的代码:
<template>
<div ref="ImgContainer" class="container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import * as _ from 'lodash';
// 导入所有的图片
let imageObj = import.meta.glob('@/assets/img/*.*', { eager: true });
let imageList = Object.values(imageObj).map((v: any) => v.default);
const imgWidth = 200; // 每张图片的宽度
const spaceWidth = 30; // 水平间隙的宽度
const spaceHeight = 30; // 垂直间隙的高度
const ImgContainer = ref();
// 1. 加入图片元素
const createImgs = () => {
for (let i = 0; i < imageList.length; i++) {
let img = document.createElement('img');
img.src = imageList[i];
img.onload = setPositions;
img.style.position = 'absolute';
img.style.width = 200 + 'px';
img.style.transition = '0.3s';
ImgContainer.value.appendChild(img);
}
};
// 设置每张图片的位置
const setPositions = () => {
ImgContainer.value.style.width = '60%'; // 重置容器的宽度
let info = cal(); // 得到列数和间隙空间
let nextTops = new Array(info.columns); // 该数组的长度为列数,每一项表示该列的下一个图片的纵坐标
nextTops.fill(0); // 数组每一项填充为0
for (let i = 0; i < ImgContainer.value.children.length; i++) {
let img = ImgContainer.value.children[i];
// 找到nextTops中的最小值作为当前图片的纵坐标
let minTop = Math.min.apply(null, nextTops);
img.style.top = minTop + 'px';
// 重新设置数组这一项的下一个top值
let index = nextTops.indexOf(minTop); // 得到使用的是第几列的top值
nextTops[index] += img.height + spaceHeight;
let left = index * info.sapce + index * imgWidth;
img.style.left = left + 'px';
}
let max = Math.max.apply(null, nextTops); // 求最大值
ImgContainer.value.style.height = max + 'px'; // 3. 设置容器的高度
};
// 计算一共有多少列,间隙的数量
const cal = () => {
let containerWidth = ImgContainer.value.clientWidth; // 容器的宽度
let columns = Math.floor(containerWidth / imgWidth); // 计算列数
let spaceNumber = columns - 1; // 间隙数量
ImgContainer.value.style.width =
columns * imgWidth + spaceNumber * spaceWidth + 'px'; // 重置容器的宽度
return {
sapce: spaceWidth,
columns,
};
};
const headleResize = _.debounce(setPositions, 300);
onMounted(() => {
createImgs();
window.addEventListener('resize', headleResize);
});
onUnmounted(() => {
window.removeEventListener('resize', headleResize);
});
</script>
<style lang="scss" scoped>
.container {
position: relative;
width: 60%;
margin: 50px auto;
}
</style>
原文链接:https://juejin.cn/post/7318717459073679412 作者:前端掘金者H