uniapp中使用瀑布流布局
1、CSS原生方案(零JS,性能最优)
.waterfall-container {
column-count: 2; /* 列数 */
column-gap: 12px; /* 列间距 */
width: 100%;
padding: 0 12px;
}
.waterfall-item {
break-inside: avoid; /* 防止元素被列截断 */
margin-bottom: 12px;
width: 100%;
}
优点:
- 代码极简,零 JS 依赖
- 浏览器原生渲染,性能最佳
- 自动响应式,支持动态列数(配合媒体查询)
缺点:
- 致命缺陷:元素按列优先排列(第一列填满再填第二列),而非视觉上的 "行优先"
- 加载更多时新元素会追加到最后一列底部,导致布局断层
- 无法控制元素的具体排列顺序
- 不支持动态调整列宽和元素位置
2、双列数组 + 动态高度计算
<template> <view class="test"> <view class="nav" :style="{ height: setMarginTop }"></view> <view class="scroll-content"> <scroll-view :scroll-y="true" :show-scrollbar="false" class="scroll-container" :style="{ height: setScrollHeight }" @scrolltolower="loadMore" @scrolltoupper="scrollToTop" > <view class="container"> <view class="waterfall_container"> <view class="wf_left"> <view v-for="item in leftColumn" :key="item.id" class="waterfall-item" :style="{ minHeight: item.minHeight + 'px' }" > <text class="item-title">{{ item.value }}</text> <text class="item-desc">{{ item.desc }}</text> <text class="item-height">实测高度:{{ item.height }}px</text> </view> </view> <view class="wf_right"> <view v-for="item in rightColumn" :key="item.id" class="waterfall-item" :style="{ minHeight: item.minHeight + 'px' }" > <text class="item-title">{{ item.value }}</text> <text class="item-desc">{{ item.desc }}</text> <text class="item-height">实测高度:{{ item.height }}px</text> </view> </view> </view> <view class="measurement-layer"> <view class="measurement-list"> <view v-for="item in measureList" :key="item.id" class="waterfall-item measure-card" :style="{ minHeight: item.minHeight + 'px' }" > <text class="item-title">{{ item.value }}</text> <text class="item-desc">{{ item.desc }}</text> </view> </view> </view> </view> </scroll-view> </view> </view> </template> <script> import { computed, defineComponent, nextTick, onMounted, reactive, toRefs } from 'vue' export default defineComponent({ name: 'test', setup() { // 当前设备信息,用来适配状态栏与可滚动区域高度 const systemInfo = uni.getSystemInfoSync(); const state = reactive({ statusBarHeight: systemInfo.statusBarHeight, // 系统状态栏高度 leftColumn: [], // 已完成测量并被分配到左列的卡片 rightColumn: [], // 已完成测量并被分配到右列的卡片 measureList: [], // 正在隐藏区域中渲染、等待读取真实高度的卡片 leftHeight: 0, // 左列当前所有卡片的累计高度 rightHeight: 0, // 右列当前所有卡片的累计高度 isMeasuring: false // 是否正在测量一批数据,防止滚动时重复追加 }); // 生成一批待测量的演示卡片,真实业务里可以替换成接口返回的数据 const createMockBatch = (count = 10) => { uni.showLoading(); // 已有卡片数量,用来保证后续追加卡片的序号连续 const startIndex = state.leftColumn.length + state.rightColumn.length + state.measureList.length; // 当前批次时间戳,用来拼接不会重复的卡片 key const batchSeed = Date.now(); setTimeout(() => { uni.hideLoading(); }, 1000); return Array.from({ length: count }, (_, index) => { // 当前卡片在整个列表中的展示序号 const currentIndex = startIndex + index + 1; // 预留的随机高度变量,恢复 minHeight 展示时可直接使用 const minHeight = Math.floor(Math.random() * 180) + 120; // 控制演示文案长短,让卡片自然产生不同的实际高度 const repeatCount = currentIndex % 4 + 1; return { id: `wf-${batchSeed}-${currentIndex}`, value: `卡片 ${currentIndex}`, desc: Array.from({ length: repeatCount }, () => '这是一段用于测量整卡高度的演示文案。').join(''), // minHeight, height: 0 }; }); }; const methods = { loadMore() { if (state.isMeasuring) { return; } methods.setMeasure(); }, scrollToTop() { console.log('返回顶部'); }, // 统一读取隐藏测量区里整张卡片的真实高度,再分发到左右列 getMeasure(retryCount = 0) { // 固定本次待测列表,避免异步回调时数据引用发生变化 const pendingList = [...state.measureList]; if (!pendingList.length) { state.isMeasuring = false; return; } // 只查询隐藏测量区的卡片,避免把已经展示的左右列一起量进去 const query = uni.createSelectorQuery(); query.selectAll('.measure-card').boundingClientRect((rects) => { // 只有数量和高度都有效时,才能按索引回填整批测量结果 const isValid = Array.isArray(rects) && rects.length === pendingList.length && rects.every((rect) => rect && rect.height); if (!isValid) { // 节点刚渲染出来时偶尔会测到空值,这里做几次重试兜底 if (retryCount < 4) { nextTick(() => { setTimeout(() => { methods.getMeasure(retryCount + 1); }, 60); }); return; } state.isMeasuring = false; return; } // 给每条待分配数据写入真实渲染高度 const measuredList = pendingList.map((item, index) => ({ ...item, height: rects[index].height })); state.measureList = []; methods.initWaterfall(measuredList); state.isMeasuring = false; }).exec(); }, // 瀑布流核心:把当前卡片放进累计高度更小的那一列 initWaterfall(list) { list.forEach((item) => { if (state.leftHeight <= state.rightHeight) { state.leftColumn.push(item); state.leftHeight += item.height; return; } state.rightColumn.push(item); state.rightHeight += item.height; }); }, // 先把卡片渲染到隐藏测量区,等 DOM 稳定后再统一读高度 setMeasure() { if (state.isMeasuring) { return; } state.isMeasuring = true; state.measureList = createMockBatch(10); nextTick(() => { setTimeout(() => { methods.getMeasure(); }, 30); }); } }; onMounted(() => { methods.setMeasure(); }); const setMarginTop = computed(() => { return state.statusBarHeight + 44 + 'px'; }); // 扣掉固定导航高度和状态栏高度后,作为瀑布流滚动区域高度 const setScrollHeight = computed(() => { return `calc(100vh - 44px - ${state.statusBarHeight}px)`; }); return { ...toRefs(state), ...methods, setMarginTop, setScrollHeight } } }); </script> <style scoped lang="scss"> .scroll-content { padding: 0 20rpx; &::before { content: ""; display: table; } } .container { position: relative; } .waterfall_container { display: grid; grid-template-columns: 1fr 1fr; gap: 20rpx; } .waterfall-item { display: flex; flex-direction: column; justify-content: space-between; margin-bottom: 20rpx; padding: 24rpx; box-sizing: border-box; background-color: #fff; border-radius: 10rpx; box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1); } .item-title { font-size: 30rpx; font-weight: 600; color: #333; } .item-desc { margin-top: 16rpx; font-size: 24rpx; line-height: 1.7; color: #666; } .item-height { margin-top: 16rpx; font-size: 22rpx; color: #999; } .measurement-layer { position: absolute; left: -9999px; top: 0; width: calc((100% - 20rpx) / 2); opacity: 0; pointer-events: none; } .measurement-list { width: 100%; } </style>
思路是将拿到的列表渲染在隐藏区,再测量所有卡片的高度并依次添加到对应的数据中,然后用forEach循环列表,通过对比左右两边的列表高度leftHeight 和rightHeight,哪个小就往哪边添加。我顺便让AI帮我优化了一下!
浙公网安备 33010602011771号