1. 起步
瀑布流是一种流行的网站页面布局方式,其特点是多栏等宽不等高的排列,随着用户滚动页面,数据块不断加载并附加至当前尾部。
注意上面的加粗部分,这就是瀑布流的主要特征,本次我们要实现瀑布流组件的封装,并针对h5端和web端适配,效果如下:
2. 实现
关于瀑布流布局的实现方案,我总共调研出来了三种方案:
- JavaScript+绝对定位实现:使用JavaScript计算每个元素的水平和垂直位置,并通过
position: absolute;
进行定位。 - Flexbox布局:将外层设置为
display: flex; flex-direction: row;
,内层每个列设置为display: flex; flex-direction: column;
。 - column属性:通过CSS3的
column-count
属性,可以指定列数,并利用break-inside: avoid;
防止内容被拆分到多列。这种方式简单易用,适合静态内容布局。
本次实现我们采用第一种实现方式,其它两种实现方式如果感兴趣的话可以去查找其它具体的资料。
在正式写代码前一定要捋清写代码的思路,使用Javascript+绝对定位的方式来实现我们要考虑的是,如何确定每个图片元素的位置(即确定left
和top
的值)?只要确定了这两个值,我们就能很简单的将每个图片定位到正确的位置了。例如我们有下面一组包含6张图片已知信息的数据,我们要来实现一个两列的瀑布流布局,图片的宽度都是200,但是高度不定,我们要如何确定每个元素要在放在哪里?下面我们通过画图的方式来一起模拟下这个过程,看看是如何计算每张图片的top
和left
值的。
let dataSource = [
{
key:1,
imgUrl:"xxxxxx",
width:200,
height:200,
left:?,
top:?
},
{
key:2,
imgUrl:"xxxxxx",
width:200,
height:300,
left:?,
top:?
},
{
key:3,
imgUrl:"xxxxxx",
width:200,
height:400,
left:?,
top:?
},
{
key:4,
imgUrl:"xxxxxx",
width:200,
height:200,
left:?,
top:?
},
{
key:5,
imgUrl:"xxxxxx",
width:200,
height:300,
left:?,
top:?
},
{
key:6,
imgUrl:"xxxxxx",
width:200,
height:400,
left:?,
top:?
},
]
首先我们正常来遍历dataSource
数组,既然是要实现一个两列的瀑布流,我们就先把前两个元素放到容器中。前两个图片都是贴着顶部的,所以它们的top
都是0。第一张图片的left
为0,第二个图片的left为第一个图片的宽度,即200:
接下来思考的是如何放第三个图片?通过上面的图,我们可以很直观的观察到,第一张图片相对来说更矮点,根据瀑布流的特点,我们应该把第三张图片放到第一张图片的下面,并且可以看出,第三张图片的left
应该是0,top
应该是200(也就是第一张图片的高度),于是我们能得到
依次类推,我们每循环到一张图片,就把它放到最短的那一列,这样当我们的图片全部循环完,我们的瀑布流布局也就完成了。
所以现在问题又来了,这是通过画图的方式,我们可以很清晰的看出哪一列更短,那么写代码的话我们要如何确认哪一列更短呢?很简单,我们可以定义一个长度为瀑布流列数的数组,数组中的每项就表示当前列的高度,这样当我们每次循环我们就知道要往哪里列去插入图片了,所以针对前面分析的两步,我们结合这个数组再来分析下
所以分析到这里,思路其实就已经很清晰了,下面我们来做一下代码的实现,我们先创建一个组件Watefall
,他要求用户传入一个图片数组的数组dataSource
,每一项的格式是这样的:
// 图片数据
export interface waterfallData {
// 图片url地址
imgUrl: string;
// 插槽区域(选填)
slot?: React.ReactNode;
}
但是这种数据格式的数组只包含了图片的地址,我们还需要知道每个图片的宽度和高度以及图片的left
和top
,所以我们还需要处理一下,来把数据处理成这样的格式。
// 瀑布流组件处理后的每项数据
export interface WaterfallItem extends waterfallData {
// 图片宽度
width: number;
// 图片高度
height: number;
// 图片水平位置
left: number;
// 图片垂直位置
top: number;
}
首先第一步,由于接收到的数据只有图片的url,所以我们要来写一个getImageSize
方法来获取到图片的尺寸信息
// 每张图片的宽度
let itemWidth = 200
// 获取图片尺寸
const getImageSize = (url: string) => {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
if (!url) {
reject({width: 0, height: 0});
}
let img = new Image();
img.src = url;
// 等待图片加载完成
img.onload = () => {
let width = img.width;
let height = img.height;
// 为了确保图片的宽度都是200,我们要针对图片宽度大于200和小于200的情况,分别重新计算
// 如果真实宽带大于每项的宽度,要等比例减少高度
if (width > itemWidth) {
height = Math.floor((itemWidth / width) * height);
}
// 如果真实宽度小于每项的宽度,要等比增加宽度
if (width < itemWidth) {
width = Math.floor((itemWidth / height) * width);
}
resolve({width, height});
};
});
};
实现完上面的方法后,我们在来实现生成们想要的数据格式方法generateDataSource
,并定义一个_dataSource
的state来保存生成的结构
// 生成dataSource数据
const generateDataSource = async (imgList: waterfallData[] = []): Promise<WaterfallItem[]> => {
if (imgList.length === 0) return Promise.resolve([]);
// 最终数据
let list: WaterfallItem[] = [];
list = imgList.map(item => {
return {
imgUrl: item.imgUrl,
width: itemWidth,
height: 0,
left: 0,
top: 0,
slot: item?.slot,
};
});
const result = await Promise.all(list.map(item => getImageSize(item.imgUrl)));
return list.map((item, index) => {
return {
...item,
height: result[index].height,
};
});
};
useEffect(() => {
generateDataSource(dataSource).then(result => {
setDataSource(result);
});
}, [dataSource]);
最后,就来到最重要的一步了,我们要实现一个方法generateImageMap
动态的去计算每项图片的位置(top和left),最终生成我们想要的结构
// 保存最终生成的结构
let [imgMap, setImgMap] = useState<any>();
const generateImageMap = () => {
// 定义一个数组保存每列的高度
let tempArr: number[] = [];
// wrapRef.current为容器的dom对象
if (wrapRef.current) {
// 通过计算容器的宽度除以每个图片的宽度,确定这个容器能放几列
let _column = Math.floor(wrapRef.current.offsetWidth / itemWidth);
// 计算剩余空间来确定每列的间距
let _space = Math.floor((wrapRef.current.offsetWidth - _column * itemWidth) / _column);
// 遍历计算每张图片的left和top
_dataSource!.forEach((item, index) => {
// 如果小于容器内存放图片的列数,则表明当前图片在第一列
if (tempArr.length < _column) {
item.top = 0;
item.left = index * (itemWidth + _space);
tempArr.push(item.height);
} else {
// 找到最短一列
let min = Math.min(...tempArr)
let minIndex = tempArr.indexOf(min);
// 计算当前遍历图片的left和top值
item.left = (minIndex % _column) * (itemWidth + _space);
item.top = min + _space;
// 累加当前列高度
tempArr[minIndex] = min + item.height + _space;
}
});
// 生成需要渲染的jsx结构
setImgMap(
_dataSource!.map((item, index) => {
return (
<div
className={styles.item}
key={index}
style={{
width: item.width + "px",
height: item.height + "px",
top: item.top + "px",
left: item.left + "px",
}}
>
<img src={item.imgUrl} alt={item.imgUrl}/>
{item?.slot ? <div>{item.slot}</div> : null}
</div>
);
}),
);
}
};
最后把imgMap
放到需要渲染的区域就大功告成了!至于h5端的适配,我们只需要监听页面的resize
方法,当页面变化,重新调用generateImageMap
方法即可,由于组件的封装要考虑到props的传参,下面的完整代码可能和上面我们写的最基本的实现不太一样,有兴趣的可以看下完整代码的实现。
3. 完整代码
目录结构如下
index.module.less
.wrap {
width: 60%;
height: 95%;
margin: 0 auto;
position: relative;
overflow: auto;
overflow-x: hidden;
.item {
position: absolute;
background: gray;
display: flex;
flex-direction: column;
overflow: hidden;
}
.img {
transition: all .3s;
}
.img:hover {
transform: scale(1.1);
}
}
index.tsx
import styles from "./index.module.less";
import { FC, useEffect, useRef, useState } from "react";
import { Props, waterfallData, WaterfallItem } from "./type";
import { isUtils } from "@m/utils";
import ImgPreview from "@/components/ImgPreview/ImgPreview.tsx";
const Waterfall: FC<Props> = ({
column,
space,
wrapWidth,
wrapHeight,
bgColor = 'transparent',
itemWidth = 200,
autoCenter = true,
dataSource,
onReachBottom
}) => {
const wrapRef = useRef<HTMLDivElement>(null);
// 生成dataSource数据
const generateDataSource = async (
imgList: string[] | waterfallData[] = [],
): Promise<WaterfallItem[]> => {
if (imgList.length === 0) return Promise.resolve([]);
// 获取图片尺寸
const getImageSize = (url: string) => {
return new Promise<{ width: number; height: number }>((resolve, reject) => {
if (!isUtils.isString(url) || !url) {
reject({width: 0, height: 0});
}
let img = new Image();
img.src = url;
img.onload = () => {
// console.log("height", img.getBoundingClientRect(), img.width, img.height);
let width = img.width;
let height = img.height;
// 如果宽带大于每项的宽度,要等比例减少高度
if (width > itemWidth) {
height = Math.floor((itemWidth / width) * height);
}
// 如果宽度小于每项的宽度,要等比增加宽度
if (width < itemWidth) {
width = Math.floor((itemWidth / height) * width);
}
resolve({width, height});
};
});
};
let list: WaterfallItem[] = [];
// 如果每一项都是字符串,说明插槽区域不需要定制
if (imgList.every(item => isUtils.isString(item))) {
list = imgList.map(item => {
return {imgUrl: item, width: itemWidth, height: 0, left: 0, top: 0};
});
} else {
// 否则按照每项都是数据处理
list = imgList.map(item => {
return {
imgUrl: item.imgUrl,
width: itemWidth,
height: 0,
left: 0,
top: 0,
slot: item?.slot ?? null,
};
});
}
const result = await Promise.all(list.map(item => getImageSize(item.imgUrl)));
return list.map((item, index) => {
return {
...item,
height: result[index].height,
};
});
};
// 生成图片列表
let [_dataSource, setDataSource] = useState<WaterfallItem[]>();
let [imgMap, setImgMap] = useState<any>();
const generateImageMap = () => {
let tempArr: number[] = [];
if (wrapRef.current) {
let _column = column ?? Math.floor(wrapRef.current.offsetWidth / itemWidth);
let _space =
space ?? Math.floor((wrapRef.current.offsetWidth - _column * itemWidth) / _column);
// 计算左侧留空距离
let leftSideWidth = 0;
if (autoCenter) {
leftSideWidth = Math.floor((wrapRef.current.offsetWidth - _column * itemWidth) / 2)
}
_dataSource!.forEach((item, index) => {
if (tempArr.length < _column) {
item.top = 0;
item.left = index * (itemWidth + _space) + leftSideWidth;
tempArr.push(item.height);
} else {
let min = Math.min(...tempArr);
let minIndex = tempArr.indexOf(min);
item.left = (minIndex % _column) * (itemWidth + _space) + leftSideWidth;
item.top = min + _space;
tempArr[minIndex] = min + item.height + _space;
}
});
setImgMap(
_dataSource!.map((item, index) => {
return (
<div
className={styles.item}
key={index}
style={{
width: item.width + "px",
height: item.height + "px",
top: item.top + "px",
left: item.left + "px",
}}
>
<div style={item.slot ? {height: "75%"} : {height: "100%"}}>
<ImgPreview
imgUrl={item.imgUrl}
className={styles.img}
style={{cursor: "default"}}
></ImgPreview>
</div>
{item?.slot ? <div style={{height: "100%"}}>{item.slot}</div> : null}
</div>
);
}),
);
}
};
useEffect(() => {
generateDataSource(dataSource).then(result => {
setDataSource(result);
});
}, [dataSource]);
useEffect(() => {
if (_dataSource && _dataSource.length > 0) {
let wrap = document.querySelector("#photoWall-wrap");
const resizeHandle = () => {
generateImageMap();
};
resizeHandle();
window.addEventListener("resize", resizeHandle);
if (wrap) {
wrap.addEventListener("scroll", () => {
if (wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - itemWidth) {
onReachBottom && onReachBottom();
}
});
}
return () => {
window.removeEventListener("resize", resizeHandle);
};
}
}, [_dataSource]);
return (
<div
ref={wrapRef}
className={styles.wrap}
id={"photoWall-wrap"}
style={{
width: wrapWidth ? wrapWidth.toString() : "60%",
height: wrapHeight ? wrapHeight.toString() : "100%",
background: bgColor
}}
>
{imgMap}
</div>
);
};
export default Waterfall;
type.d.ts
// 瀑布流组件接收参数
import React from "react";
export interface Props {
// 列数
column?: number;
// 间距
space?: number;
// 容器宽度
wrapWidth?: string;
// 容器高度
wrapHeight?: string;
// 每项的宽度 default: 200
itemWidth?: number;
// 当容器宽度两边留白时,是否将元素剧中
autoCenter?: boolean;
// 图片数据
dataSource: waterfallData[];
// 容器背景颜色,default:transparent
bgColor?: string;
// 触底操作,当容器滚动到底部时触发
onReachBottom?: () => void;
}
// 图片数据
export interface waterfallData {
// 图片地址
imgUrl: string;
// 插槽区域
slot?: React.ReactNode;
}
// 瀑布流组件处理后的每项数据
export interface WaterfallItem extends waterfallData {
// 图片宽度
width: number;
// 图片高度
height: number;
// 图片水平位置
left: number;
// 图片垂直位置
top: number;
}
使用方法
// 导包
import Waterfall from "@/components/Watefall";
//----------------------------------------------------
// 使用
<Waterfall
dataSource={imgList}
onReachBottom={() => {
console.log('---触底了---');
}}
/>