前言
做了7年前端我一直不知道瀑布流是什么(怪设计师不争气啊,哈哈哈),我一直以为就是个普通列表,几行css解决的那种。
当然瀑布流确实有css解决方案,但是这个方案对于分页列表来说完全不能用,第二页内容一出来位置都变了。
我看了一下掘金的一些文章,好长啊,觉得还是自己想一下怎么写吧。就自己实现了一遍。希望思路给大家一点帮助。
分析瀑布流
以小红书的瀑布流为例,相同宽度不同高度的卡片堆叠在一起形成瀑布流。
这里有两个难点:
-
卡片高度如何确定?
-
堆叠布局如何实现?
卡片的高度 = padding + imageHeight + textHeight....
不固定的内容包括:图片高度、标题行数
也就是说当我们解决了图片和标题的高度问题,那么瀑布流的第一个问题就解决了。(感觉已经写好代码了一样)
堆叠问题——因为css没有这样的布局方式,所以肯定得用js实现。最简单的解决方案就是对每一个盒子进行绝对定位。
这个问题就转换成计算现有盒子的定位问题。
从问题到代码
第一个问题——图片高度
无论是企业业务场景还是个人开发,通过后端返回图片的width、height都是合理且轻松的。
前端去获取图片信息,无疑让最重要的用户体验变得糟糕。前端获取图片信息并不困难,但是完全没有必要。
所以我直接考虑后端返回图片信息的情况。
typescript代码解读复制代码const realImageHeight = imageWidth / imageHeight * cardContentWidth;
图片高度轻松解决,无平台差异
第二个问题——文字高度
从小红书可以看出,标题有些两行有些一行,也有些三行。
如果你固定一行,这个问题完全可以跳过。
- 方案一:我们可以用字数和宽度来计算可能得行数
优势:速度快,多平台复用
劣势:不准确(标题包括英文中文) - 方案二:我们可以先渲染出来再获取行数
优势:准确
劣势:相对而言慢,不同平台方法不同
准确是最重要的!选择方案二
其实方案二也有两种方案,一种是用canvas模拟,这样可以最大限度摆脱平台(h5、小程序)的限制,
然而我试验后,canvas还没找到准确的计算的方法(待后续更新)
第二种就是用div渲染一遍,获取行数或者高度。
创建一个带有指定样式的 div 元素
typescript代码解读复制代码function createDiv(style: string): HTMLDivElement { const div = document.createElement(''div''); div.style.cssText = style; document.body.appendChild(div); return div; }
计算文本数组在指定字体大小和容器宽度下的行数
typescript代码解读复制代码/** * 计算文本数组在指定字体大小和容器宽度下的行数 * @param texts - 要渲染的文本数组 * @param fontSize - 字体大小(以像素为单位) * @param lineHeight - 字体高度(以像素为单位) * @param containerWidth - 容器宽度(以像素为单位) * @param maxLine - 最大行数(以像素为单位) * @returns 每个文本实际渲染时的行数数组 */ export function calculateTextLines( texts: string[], fontSize: number, lineHeight: number, containerWidth: number, maxLine?: number ): number[] { // 创建一个带有指定样式的 div 元素 const div = createDiv(`font-size: ${fontSize}px; line-height: ${lineHeight}px; width: ${containerWidth}px; white-space: pre-wrap;`); const results: number[] = []; texts.forEach((text) => { div.textContent = text; // 获取 div 的高度,并根据字体大小计算行数 const divHeight = div.offsetHeight; const lines = Math.ceil(divHeight / lineHeight); maxLine && lines > maxLine ? results.push(maxLine) : results.push(lines); }); // 清理 div removeElement(div); return results; }
这个问题小程序如何解决放在文末
第三个问题——每个卡片的定位问题
解决了上面的问题,就解决了盒子高度的问题,这个问题完全就是相同宽度不同高度盒子的堆放问题了
问题的完整描述是这样的:
写一个ts函数实现将一堆小盒子,按一定规则顺序推入大盒子里
函数输入:小盒子高度列表
���盒子:不同小盒子高度不一致,宽度为stackWidth,彼此间隔gap
大盒子:高度无限制,宽度为width
堆放规则:优先放置高度低的位置,高度相同时优先放在左侧
返回结果:不同盒子的高度和位置信息
如果你有了这么清晰的描述,接下去的工作你只需要交给gpt来写你的函数
typescript代码解读复制代码// 返回的盒子信息 export interface Box { x: number; y: number; height: number; } // 盒子堆叠的方法类 export class BoxPacker { // 返回的小盒子信息列表 private boxes: Box[] = []; // 大盒子宽度 private width: number; // 小盒子宽度 private stackWidth: number; // 小盒子间隔 private gap: number; constructor(width: number, stackWidth: number, gap: number) { this.width = width; this.stackWidth = stackWidth; this.gap = gap; this.boxes = []; } // 添加单个盒子 public addBox(height: number): Box[] { return this.addBoxes([height]); } // 添加多个盒子(一般用这个方法) public addBoxes(heights: number[], isReset?: boolean): Box[] { isReset && (this.boxes = []) console.log(''this.boxes—————— '', JSON.stringify(this.boxes) ) for (const height of heights) { const position = this.findBestPosition(); const newBox: Box = { x: position.x, y: position.y, height }; this.boxes.push(newBox); } return this.boxes; } // 查找定位函数 private findBestPosition(): { x: number; y: number } { let bestX = 0; let bestY = Number.MAX_VALUE; for (let x = 0; x <= this.width - this.stackWidth; x += this.stackWidth + this.gap) { const currentY = this.getMaxHeightInColumn(x, this.stackWidth); if (currentY < bestY || (currentY === bestY && x < bestX)) { bestX = x; bestY = currentY; } } return { x: bestX, y: bestY }; } private getMaxHeightInColumn(startX: number, width: number): number { return this.boxes .filter(box => box.x >= startX && box.x < startX + width) .reduce((maxHeight, box) => Math.max(maxHeight, box.y + box.height + this.gap), 0); } }
这样我们就实现了根据高度获取定位的功能了
来实现一波
核心的代码就是获取每个盒子的定位、宽高信息
typescript代码解读复制代码// 实例 const boxPacker = useMemo(() => { return new BoxPacker(width, stackWidth, gap) }, []); const getCurrentPosition = (currentData: DataItem[], reset?: boolean) => { // 获取标题文本行数列表 const textLines = calculateTextLines(currentData.map(item => item.title),card.fontSize,card.lineHeight, cardContentWidth) // 获取图片高度列表 const imageHeight = currentData.map(item => (item.imageHeight / item.imageWidth * cardContentWidth)) // 获取小盒子高度列表 const cardHeights = imageHeight.map((h, index) => ( h + textLines[index] * card.lineHeight + card.padding * 2 + (card?.otherHeight || 0) ) ); // 获取盒子定位信息 const boxes = boxPacker.addBoxes( cardHeights, reset ) // 返回盒子列表信息 return boxes.map((box, index) => ({ ...box, title: currentData[index]?.title, url: currentData[index]?.url, imageHeight: imageHeight[index], })) }
set获取到的盒子信息
typescript代码解读复制代码const [boxPositions, setBoxPositions] = useState<(Box & Pick<DataItem, ''url'' | ''title'' | ''imageHeight''>)[]>([]); useEffect(() => { // 首次和刷新 if (page === 1) { setBoxPositions(getCurrentPosition(data, true)) } else { // 加载更多 setBoxPositions(getCurrentPosition(data.slice((page - 1) * pageSize, page * pageSize))) } }, [])
效果如下
小程序获取文本高度
从上面的分析可以看出来只有文本高度实现是不同的,如果canvas方案实验成功,说不定还能做到大一统。
目前没成功大家就先看看我的目前方案:先实际渲染文字然后读取信息,然后获取实际高度
typescript代码解读复制代码import React, {useEffect, useMemo, useState} from ''react'' import { View } from ''@tarojs/components'' import Taro from "@tarojs/taro"; import ''./index.less'' import {BoxPacker} from "./flow"; const data = [ ''vwyi这是一个标题,这是一个标题,这是一个标题,这是一个标题'', ''这是一个标题'', ''这是一个标题,这是一个标题,这是一个标题,这是一个标题'', ''这是一个标题'', ''这是一个标题,这是一个标题,这是一个标题,一个标题'', ''这是一个标题,这是一个标题,这是一个标题,这题'', ''这是一个标题,这是一个标题,这是一'', ''这是一个标题,这是一个标题,这是一'', ]; function Index() { const boxPacker = useMemo(() => new BoxPacker(320, 100, 5), []); const [boxPositions, setBoxPositions] = useState<any[]>([]) function getTextHeights() { return new Promise((resolve, reject) => { Taro.createSelectorQuery() .selectAll(''#textContainer .text-item'') .boundingClientRect() .exec(res => { if (res && res[0]) { const heights = res[0].map(item => item.height); resolve(heights); } else { reject(''No buttons found''); } }); }); } useEffect(() => { getTextHeights().then(h => { setBoxPositions(boxPacker.addBoxes(h)) }) }, []) return ( <View className="flow-container"> <View id="textContainer"> { data.map((item, index) => (<View key={index} className="text-item">{item}</View>)) } </View> <View className="text-box-container"> {boxPositions.map((position, index) => ( <View key={index} className="text-box" style={{ left: `${position.x}px`, top: `${position.y}px`, height: `${position.height}px`, width: ''100px'', // 假设盒子的宽度固定为100px }} > {`${data[index]}`} </View> ))} </View> </View> ) } export default Index