导语
在前端领域,经常会遇到瀑布流布局的开发,最近整理了下相关的使用场景和解决方案,其中包含了简单算法 DP,前端基础知识,业务场景的思考。
什么是瀑布流布局
瀑布流又称瀑布流式布局,是一种比较流行的页面布局方式,英文名称为:Masonry Layouts 。与传统的分页显示不同,视觉上表现为参差不齐的多栏布局,最早由 Pinterest 首先运用。
特别是在移动端,双列瀑布流的应用更加常见,在展现呈现每个元素能够以自身的情况合理占据空间,每个元素宽高不一致,左右依次调整排列,最终占据最小的屏幕高度,配合无限加载的设计,无论从用户使用心理的考虑、展示的美观,用户体验等方面考虑,瀑布流都是一种相当优秀的布局方式。
以腾讯课堂APP的瀑布流为例:
01
使用场景
根据瀑布流的优缺点,我们不难得出在什么情况下选择瀑布流是合理的选择:
这里引用了一篇文章的总结,瀑布流能够有效引导用户利用碎片化的时间,尽可能获得最大化的用户留存和使用时间。
如何实现瀑布流布局
结合前人的总结,目前实现瀑布流方式有 multi-column , grid , Flexbox 三种,实现方案各有不同,这里就不给大家具体说明了,各位不了解的请自行Google。从兼容性及易用性综合考虑,还是推荐使用 Flexbox的布局方案。
一般来说HTML结构如下:(以微信小程序为例)
<view class="container">
<view class="column-container">
<template
is="item-card"
wx:key="{{item.id}}"
wx:for="{{left}}"
data="{{...item}}" />
</view>
<view class="column-container">
<template
is="item-card"
wx:key="{{item.id}}"
wx:for="{{right}}"
data="{{...item}}" />
</view>
</view>
上面的代码中,container 代表瀑布流容器,负责滚动和触发无限加载;column-container 是列容器,item-card 是其中的每一项卡片。
相应的 CSS 设置如下:
.container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
margin-top: 12px;
> .column-container {
flex: 1 1 0;
margin: 4px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
}
}
瀑布流容器的 flex 设置横向布局,列容器为纵向布局。
对应的数据元组也分为下面这些,couponList 是总数据,left 是分配到左边的一列的数据,right 是分配右边一列的数据。具体优化分配方式是后续分析的重点,这里先按照下表进行分析。
Page({
data: {
couponList: [],
left: [],
right: [],
},
}
直到这里,我们才真正进入本文的重点:怎么做一个高性能,高体验的H5双列瀑布流?
这里我们先选定一个使用场景,技术实现上选用 Flexbox 实现布局,数据加载方面要求无限向下滚动加载,能够方便大家更加关注具体的业务背景,也降低作为作者介绍优化的范围,便于讲述。如果有其他场景,可以在留言区里大家一起讨论,在这里就不做大而全的讲述了。
准确来说,在双列瀑布流的使用场景中,围绕元素卡片高度是否固定,顺序是否严格固定,可以分为元素高度分化场景、顺序分化场景,具体如下:
元素高度分化场景:
顺序分化场景:(结合无限加载为前提)
下面我们就具体的场景来一一分析,并优化其中的实现细节。
let i = 0;
while (i <couponList.length) {
left.push(couponList[i++]);
if (i < couponList.length) {
right.push(couponList[i++]);
}
}
function computeRatioHeight (data) {
// 计算当前元素相对于屏幕宽度的百分比的高度
// 设计稿的屏幕宽度
const screenWidth = 375;
//设计稿中的元素高度,也可以前端根据类型约定
const itemHeight = data.height;
return Math.ceil(screenWidth / itemHeight * 100);
}
function formatData(data) {
let diff = 0;
const left = [];
const right = [];
let i = 0;
while(i < data.length) {
if (diff <= 0) {
left.push(data[i]);
diff += computeRatioHeight(data[i]);
} else {
right.push(data[i]);
diff -= computeRatioHeight(data[i]);
}
i++;
}
return { left, right }
}
function getImgInfo(url) {
return new Promise((resolve, reject) => {
// 创建对象
const img = new Image();
// 改变图片的src
img.src = img_url;
// 判断是否有缓存
if (img.complete) {
resolve({ width: img.width, height: img.height });
} else {
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
}
})
}
进阶优化
01
误差矫正
在 A2 场景中,每个卡片的高度并不能像预想的高度去精确渲染,特别是在移动端 H5 中使用 Rem 单位、适配不同的设备类型的场景中,计算的精度差,渲染的像素误差,都会给计算左右高度差时带来误差一定的误差,在无限滚动的基础上,这种误差会持续累积,最终导致布局策略的失败。因此需要对左右高度差在每次加载数据后进行矫正。
这里采用的方式比较简单,可以在左右列容器的尾部增加一个高度为0px的隐藏锚点元素,每次渲染结束后获取锚点元素的 offsetTop 的值,更新左右两侧的高度差。
下面是 HTML 结构:
<view class="container">
<view class="column-container">
<template
is="item-card"
wx:key="{{item.id}}"
wx:for="{{left}}"
data="{{...item}}" />
<view class="hidden-archer" id="left-archer" />
</view>
<view class="column-container">
<template
is="item-card"
wx:key="{{item.id}}"
wx:for="{{right}}"
data="{{...item}}" />
<view class="hidden-archer" id="right-archer" />
</view>
</view>
下面是小程序的更新差值的代码:
this.setData({
left: [...left, ...leftData],
right: [...right, ...rightData],
diffValue: diffValue + nextDiff,
}, () => {
// 更新左右间距差
const query = wx.createSelectorQuery();
console.log('计算高度差');
query.select('#left-archer').boundingClientRect();
query.select('#right-archer').boundingClientRect();
const { diffValue } = this.data;
query.exec((res) => {
console.log(res[0].top - res[1].top, diffValue);
this.setData({
diffValue: res[0].top - res[1].top,
});
});
});
leftData, rightData 是新增的排列数据,diffValue 是左右两列高度差值,事实证明 diffValue 和 左右锚点的高度差值存在误差(如下图),需要通过这种手段矫正下。
02
通过DP算法获取最优排列
在 A2 场景下,通过计算高度差向高度低的一列添加元素,实际并不是完美方案,因为在极端场景下,例如最后一个元素过高,会导致底部左右的高度差过大,甚至超过一个常见元素的高度,一方面没有合理使用屏幕高度,另外一方面巨大的高度差也会给用户体验带来负面影响。
为了解决这种问题,我们引入简单的 DP算法来解决这个问题。假如已知所有待排列元素的高度,就可以计算出这些元素的真实占据的高度-记为总高度 H,假如不考虑卡片不可分割的特性,将两个列容器想想成联通的两个水柱,那么其元素总高度 H / 2 就是其最佳占据高度,由于很难出现左右排列高度一致的情况,因此获取最靠近 H / 2 的排列高度即为最佳排列高度,进而转换成背包问题就是在 H / 2 容量的背包里,如何放置尽可能使用其空间体积的题目,下面就按照这个思路来解决如何获取最优的问题。
resetLayoutByDp: function (couponList) {
const { left, right, diffValue } = this.data;
const heights = couponList.map(item => (item.height / item.width * 160 + 77));
const bagVolume = Math.round(heights.reduce((sum, curr) => sum + curr, diffValue) / 2);
let dp = [];
//......省略部分代码
// 具体的DP算法可以自行查阅相关资料
const rightIndex = dp[heights.length - 1][bagVolume].indexes;
const nextDiff = heights.reduce((target, curr, index) => {
if (rightIndex.indexOf(index) === -1) {
target += heights[index];
} else {
target -= heights[index];
}
return target;
}, 0);
const rightData = rightIndex.map(item => couponList[item]);
const leftData = couponList.reduce((target, curr, index) => {
if (rightIndex.indexOf(index) === -1) {
target.push(couponList[index]);
}
return target;
}, []);
this.setData({
left: [...left, ...leftData],
right: [...right, ...rightData],
diffValue: diffValue + nextDiff,
}, () => {
// 更新左右间距差
const query = wx.createSelectorQuery();
console.log('计算高度差');
query.select('#left-archer').boundingClientRect();
query.select('#right-archer').boundingClientRect();
const { diffValue } = this.data;
query.exec((res) => {
console.log(res[0].top - res[1].top, diffValue);
this.setData({
diffValue: res[0].top - res[1].top,
});
});
});
}
03
优化列容器中的排列
在实际业务场景中,常常会对排列顺序有要求,常见于广告和推荐的算法中,这里前端也可以做一些优化。这里的手段主要列容器内部的排序和不同列容器的相同元素的置换,尽可能保证高优先级的元素出现靠前的位置。
最终的效果演示如下:
紧追技术前沿,深挖专业领域
扫码关注我们吧!