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帮我优化了一下!

posted on 2026-05-23 11:07  久居我梦  阅读(3)  评论(0)    收藏  举报

导航