我重生了,魂穿到了一个落魄前端程序员身上,凭借着前生大牛的记忆,我将改变这个人的职业生涯。此时领导坐在我面前,说想要一个类似淘宝小红书那样的瀑布流布局,我斜嘴一笑,这对我来说岂不是手到擒来。
我们常见的布局方式有弹性布局、网格布局、表格布局等,这些布局方式都比较容易实现。而瀑布流布局的每个子元素高度都不相同,例如淘宝、小红书等。这种布局就需要js的帮助了。
我们的基本思路是:将卡片插入布局时计哪列高度最小,则将卡片插入该列。利用transform: "translate()"
样式设置每个卡片的位置,我们还需要知道卡片分几列,然后根据容器宽度计算出每列的宽度,卡片之间还需要自定义间距,这样才会好看。
这样就确定了组件的几个props:
- colSpan:卡片分几列
- gap:卡片的横向和纵向间隔
typescript
<script setup lang="ts">
import {WaterfallFlowService} from "./service";
import {computed, onMounted, Ref, ref} from "vue";
export interface Props {
colSpan?: number;
gap?: [number, number];
}
const props = withDefaults(defineProps<Props>(), {
colSpan: 2,
gap: () => [20, 20],
})
const emit = defineEmits<{
(e: 'resize', width: number): void;
}>()
const waterfallFlowRef: Ref<HTMLElement | null> = ref(null)
const waterfallInnerRef: Ref<HTMLElement | null> = ref(null)
// 核心类包含了布局的核心方法
const service = new WaterfallFlowService(props.colSpan, props.gap);
onMounted(() => {
// 页面挂载后和监听到resize事件时调用resize方法
resize()
function resize() {
// resize方法获取容器宽度,并调用reLayout方法
if (!waterfallInnerRef.value) return;
const rect = waterfallInnerRef.value.getBoundingClientRect();
service.reLayout(rect.width);
emit("resize", rect.width)
}
window.addEventListener("resize", resize)
})
// 容器高度
const height = computed(() => `${service.model.height}px`)
// 将核心方法导出去
defineExpose(service)
</script>
html
<template>
<div ref="waterfallFlowRef" class="waterfall-flow" @scroll="onScroll">
<div ref="waterfallInnerRef" class="waterfall-inner">
<!-- 遍历渲染waterfalls,里面需要包含卡片的位置信息 -->
<div
v-for="data in service.model.waterfalls"
class="waterfall-item"
:style="{
width: data.width + 'px',
height: data.height + 'px',
transform:`translate(${data.x}px, ${data.y}px)`,
}">
</div>
<div>{{data}}</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.waterfall-flow {
position: relative;
width: 100%;
height: 100%;
overflow: auto;
box-sizing: border-box;
.waterfall-inner {
width: 100%;
height: v-bind(height);
.waterfall-item {
position: absolute;
}
}
.state-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 30px;
}
}
</style>
下面封装组件的核心类
typescript
import {reactive} from "vue";
// 定义每张卡片需要的数据格式
interface WaterfallItem {
x: number;
y: number;
width: number;
height: number;
data: Record<string, unknown>
}
interface Model {
waterfalls: WaterfallItem[];
width: number;
height: number;
unitWidth: number;
}
export class WaterfallFlowService{
static StateBoxHeight = 40;
model: Model = reactive({
// 存放所有卡片的列表
waterfalls: [],
// 容器宽度
width: 0,
// 容器高度
height: 0,
// 卡片宽的
unitWidth: 0
})
// 记录每一列的当前高度
colHeight: number[] = []
constructor(
public colSpan: number,
public gap: [number, number]
) { }
// 页面挂载后和监听到resize事件时调用reLayout方法
// 记录容器宽度、计算每张卡片的宽度
reLayout(width: number) {
this.model.width = width;
const [col] = this.gap;
this.model.unitWidth = (width - (this.colSpan - 1) * col) / this.colSpan;
// 从新设置每张卡片的布局
this.resetWaterfall()
}
resetWaterfall() {
// 根据列数初始化每列的当前高度
this.colHeight = new Array(this.colSpan).fill(0);
// data2Waterfall方法会从新设置每张卡片的位置信息
this.model.waterfalls = this.model.waterfalls.map(
(item, index) => this.data2Waterfall(item.data)
);
// 由于我们的卡片都是绝对布局,所以卡片并不会撑起容器的高度,所以需要单独设置容器高度,容器有了高度才能滚动。
this.formatHeight()
}
// 添加卡片的方法dataList里时业务数据,调用data2Waterfall转换成卡片数据
appendData(dataList: Array<Record<string, unknown>>) {
dataList.forEach((data) => {
this.model.waterfalls.push(this.data2Waterfall(data))
})
// 添加卡片也需要设置容器高度
this.formatHeight()
}
// 将业务数据转换成卡片数据,业务数据中必须包含高度信息
data2Waterfall(data: Record<string, unknown>): WaterfallItem {
// findNextCol找到当前高度最小的列
const colIdx = this.findNextCol()
const [colGap, rowGap] = this.gap;
// 取出高度最小列的高度
const currentHeight = this.colHeight[colIdx];
const res = {
// 卡片宽度就是单位宽度
width: this.model.unitWidth,
// 高度 = 业务数据指定的卡片高度
height: (data[height] as number | undefined) || 60,
// x = 第几列 * 单位宽 + 列间距
x: colIdx * this.model.unitWidth + colIdx * colGap,
// y = 当前列高度 + 行间距
y: currentHeight ? currentHeight + rowGap : currentHeight,
// 再将业务数据保存下来,组件外会用到
data
}
// 更新当前列的高度
this.colHeight[colIdx] = res.y + res.height;
return res;
}
// 查找高度最小的列
findNextCol() {
if (!this.colHeight.length) return 0;
const min = Math.min(...this.colHeight)
return this.colHeight.findIndex((item) => item === min)
}
// 根据最大的列高设置容器的高度
formatHeight() {
this.model.height = Math.max(...this.colHeight);
}
}
在业务组件里使用我们封装的瀑布流组件
html
<template>
<div class="waterfall-flow-page">
<WaterfallFlow
ref="waterfallFlowRef"
v-model:loading="model.loading"
:colSpan="4"
></WaterfallFlow>
</div>
</template>
<style lang="scss" scoped>
.waterfall-flow-page {
width: 100%;
height: 750px;
background: #ffffff;
box-sizing: border-box;
}
</style>
typescript
<script setup lang="ts">
import {onMounted, reactive, Ref, ref} from "vue";
import WaterfallFlow from "@/components/WaterfallFlow";
import { WaterfallFlowService } from "@/components/WaterfallFlow/src/service";
// 获取组件实例
const waterfallFlowRef: Ref<WaterfallFlowService | null> = ref(null)
// 初始化时设置数据
onMounted(() => {
loadData()
})
const model = reactive({
loading: false
})
let count = 0
function loadData() {
for (let i = 0;i < 20;i++) {
waterfallFlowRef.value?.appendData([{
name: count++,
height: Math.floor(Math.random()*(301-100)+100)
}])
}
}
</script>
为了让业务组件更好的定义卡片样式,所以我们将卡片作为插槽暴露出去
修改组件
xml
<template>
<div ref="waterfallFlowRef" class="waterfall-flow" @scroll="onScroll">
<div ref="waterfallInnerRef" class="waterfall-inner">
<div
v-for="data in service.model.waterfalls"
class="waterfall-item"
:style="{
width: data.width + 'px',
height: data.height + 'px',
transform:`translate(${data.x}px, ${data.y}px)`,
}"
>
<!-- 将业务数据和位置信息传递出去 -->
<slot :data="data.data" :width="data.width" :height="data.height" :x="data.x" :y="data.y"></slot>
</div>
</div>
</div>
</template>
修改业务组件
xml
<template>
<div class="waterfall-flow-page">
<WaterfallFlow
ref="waterfallFlowRef"
v-model:loading="model.loading"
:colSpan="4"
@load="onLoad"
>
<!-- 使用插槽自定义卡片 -->
<template #default="{data, y}">
<div class="item">
<div>{{data}}-{{y}}</div>
</div>
</template>
</WaterfallFlow>
</div>
</template>
<style lang="scss" scoped>
.waterfall-flow-page {
width: 100%;
height: 750px;
background: #ffffff;
box-sizing: border-box;
.item {
width: calc(100% - 2px);
height: calc(100% - 2px);
border: 1px solid red;
}
}
</style>
查看效果
这样瀑布流布局就完成了
我们还需要一个触底加载更多数据的功能 首先定义几个状态
typescript
const props = withDefaults(defineProps<Props>(), {
colSpan: 2,
gap: () => [20, 20],
// 是否正在加载
loading: false,
// 是否全部加载完成
finish: false,
// 是否加载失败
error: false,
// 离底部多少距离出发加载事件
loadOffset: 40
})
定义事件
arduino
const emit = defineEmits<{
// 将loading做成双向绑定
(e: 'update:loading', loading: boolean): void;
// 加载事件
(e: 'load'): void;
(e: 'resize', width: number): void;
}>()
监听滚动条事件
html
<template>
<!-- 使用@scroll监听滚动条事件 -->
<div ref="waterfallFlowRef" class="waterfall-flow" @scroll="onScroll">
<div ref="waterfallInnerRef" class="waterfall-inner">
<div
v-for="data in service.model.waterfalls"
class="waterfall-item"
:style="{
width: data.width + 'px',
height: data.height + 'px',
transform:`translate(${data.x}px, ${data.y}px)`,
}">
<slot :data="data.data" :width="data.width" :height="data.height" :x="data.x" :y="data.y"></slot>
</div>
</div>
// 显示当前加载状态,同样也支持插槽自定义
<div class="state-container">
<slot v-if="props.loading" name="loading">加载中...</slot>
<slot v-if="props.error" name="error">加载失败</slot>
<slot v-if="props.finish" name="finish">没有更多数据了</slot>
</div>
</div>
</template>
typescript
function onScroll() {
if (!waterfallFlowRef.value) return;
const rect = waterfallFlowRef.value.getBoundingClientRect()
const offset = waterfallFlowRef.value.scrollHeight - rect.height - waterfallFlowRef.value.scrollTop
// 如果滚动条离底部小于预置高度,并且loading、error、finish都为false,则触发加载事件
if(offset < props.loadOffset && !props.loading && !props.error && !props.finish) {
// 将loading变为true,避免触底重复触发load
emit("update:loading", true)
emit("load")
}
}
业务组件监听load事件加载数据
ini
<template>
<div class="waterfall-flow-page">
<WaterfallFlow
ref="waterfallFlowRef"
v-model:loading="model.loading"
:colSpan="4"
:finish="model.finish"
@load="onLoad"
>
</WaterfallFlow>
</div>
</template>
ts
const model = reactive({
loading: false,
finish: false
})
function onLoad() {
console.log('onLoad')
loadData()
}
let count = 0
function loadData() {
model.loading = true;
// 模拟接口请求数据
setTimeout(() => {
for (let i = 0;i < 20;i++) {
waterfallFlowRef.value?.appendData([{
name: count++,
height: Math.floor(Math.random()*(301-100)+100)
}])
}
model.loading = false;
if(count >= 1000) {
// 加载1000条数据后将finish设为true将不会再触发load事件
model.finish = true
}
}, 1000)
}
像淘宝,当你点进去某个卡片再出来后,它可能会认为你对这个商品感兴趣,于是会在卡片旁边插入一个类似的商品。所以我们的组件还需要在指定位置插入新卡片的功能,同样删除修改指定位置的卡片也需要,我们只要在指定位置插入数据,然后将该位置之后的卡片重新布局就可以了。