uni-app实现瀑布流展示

在UniApp中封装一个可自定义列数的瀑布流组件,可以通过Flex布局结合JavaScript动态计算来实现。这种方法兼容性好,能灵活控制列数,并提供良好的用户体验。下面是一个详细的实现方案。

组件设计思路

  1. 布局选择:使用Flex布局创建多列容器,每列是一个独立的竖向排列的容器,项目按顺序插入到当前高度最小的列中,以实现瀑布流效果。
  2. 列数自定义:通过组件的columnCount属性允许用户指定列数,默认值为2。可以根据屏幕宽度动态调整列数,例如在宽屏下显示3列,窄屏下显示2列。
  3. 数据管理:组件接收一个数组作为数据源,每个数据项应包含用于渲染图片、标题和描述等内容的字段。同时,支持通过插槽自定义内容渲染。
  4. 高度计算:对于每个数据项,需要计算其高度以正确布局。如果数据项包含图片,可以通过uni.getImageInfo获取图片原始宽高,然后根据容器宽度和图片宽高比计算显示高度。如果数据项高度固定或由内容撑开,也可以直接使用固定高度或通过CSS控制。
  5. 性能优化:使用异步加载图片信息,避免阻塞主线程。在图片加载完成后更新布局。对于大量数据,可以考虑虚拟滚动,但本例暂不实现。

具体实现代码

组件结构 (Waterfall.vue)

点击查看代码
<template>
  <view class="waterfall-container">
    <view 
      v-for="(column, index) in columns" 
      :key="index" 
      class="waterfall-column"
      :style="{ width: columnWidth, 'margin-left': index > 0 ? gap : 0 }"
    >
      <view 
        v-for="item in column" 
        :key="item[keyName]" 
        class="waterfall-item"
        :style="{ 'margin-bottom': itemMarginBottom }"
      >
        <slot name="item" :item="item">
          <!-- 默认插槽内容 -->
          <image 
            v-if="item[imageKey]" 
            :src="item[imageKey]" 
            mode="widthFix" 
            :style="{ width: '100%' }"
            @load="onImageLoad($event, item)"
            @error="onImageError"
          ></image>
          <text class="item-title">{{ item[nameKey] }}</text>
          <text class="item-desc">{{ item[textKey] }}</text>
        </slot>
      </view>
    </view>
  </view>
</template>

<script>
export default {
  name: 'Waterfall',
  props: {
    // 数据源数组
    list: {
      type: Array,
      default: () => []
    },
    // 数据项中唯一标识的字段名
    keyName: {
      type: String,
      default: 'id'
    },
    // 图片字段名
    imageKey: {
      type: String,
      default: 'image'
    },
    // 标题字段名
    nameKey: {
      type: String,
      default: 'name'
    },
    // 描述字段名
    textKey: {
      type: String,
      default: 'text'
    },
    // 列数
    columnCount: {
      type: Number,
      default: 2
    },
    // 列间距(rpx)
    gap: {
      type: String,
      default: '20rpx'
    },
    // 项目底部间距(rpx)
    itemMarginBottom: {
      type: String,
      default: '20rpx'
    }
  },
  data() {
    return {
      columns: [], // 存储分配后的列数据
      itemHeights: {} // 存储每个项目的高度,用于动态计算布局
    }
  },
  computed: {
    // 计算每列的宽度(考虑列间距)
    columnWidth() {
      // 将列间距从rpx转换为像素,需要获取屏幕宽度
      const systemInfo = uni.getSystemInfoSync()
      const pixelRatio = 750 / systemInfo.windowWidth
      const gapPx = parseInt(this.gap) / pixelRatio // 转换为px
      
      // 总宽度减去所有间隙后均分
      const totalGap = gapPx * (this.columnCount - 1)
      const width = (systemInfo.windowWidth - totalGap) / this.columnCount
      return `${width}px`
    }
  },
  watch: {
    // 监听数据源变化,重新分配项目
    list: {
      handler(newVal) {
        this.distributeItems(newVal)
      },
      immediate: true,
      deep: true
    },
    // 监听列数变化,重新分配项目
    columnCount() {
      this.distributeItems(this.list)
    }
  },
  methods: {
    // 分配项目到各列
    distributeItems(items) {
      // 初始化列数组和高度数组
      const newColumns = Array.from({ length: this.columnCount }, () => [])
      const columnHeights = new Array(this.columnCount).fill(0)
      
      // 遍历所有项目
      items.forEach(item => {
        // 找到高度最小的列
        const minHeight = Math.min(...columnHeights)
        const columnIndex = columnHeights.indexOf(minHeight)
        
        // 将项目添加到该列
        newColumns[columnIndex].push(item)
        
        // 更新该列的高度:加上当前项目的高度和底部间距
        // 如果项目高度已知,则使用已知高度;否则使用默认高度或等待图片加载
        const itemHeight = this.itemHeights[item[this.keyName]] || this.getEstimatedItemHeight(item)
        columnHeights[columnIndex] += itemHeight + this.parseRpxToPx(this.itemMarginBottom)
      })
      
      this.columns = newColumns
    },
    
    // 解析rpx单位为px
    parseRpxToPx(rpxValue) {
      const systemInfo = uni.getSystemInfoSync()
      const pixelRatio = 750 / systemInfo.windowWidth
      return parseInt(rpxValue) / pixelRatio
    },
    
    // 获取预估的项目高度(用于初始布局)
    getEstimatedItemHeight(item) {
      // 如果项目已经有计算过的高度,则返回
      if (this.itemHeights[item[this.keyName]]) {
        return this.itemHeights[item[this.keyName]]
      }
      
      // 否则返回一个预估高度,例如200px
      // 在实际应用中,可以根据项目内容提供更精确的预估
      return 200
    },
    
    // 图片加载完成事件
    onImageLoad(e, item) {
      // 获取图片原始宽高
      const { width: originalWidth, height: originalHeight } = e.detail
      
      // 计算图片在容器中的显示高度(容器宽度已知,根据宽高比计算)
      const columnWidthPx = this.parseWidthToPx(this.columnWidth)
      const displayHeight = (originalHeight / originalWidth) * columnWidthPx
      
      // 存储项目高度(图片高度加上标题和描述的高度)
      // 这里简单起见,假设标题和描述总高度为80px,实际应根据内容计算
      const totalItemHeight = displayHeight + 80
      this.$set(this.itemHeights, item[this.keyName], totalItemHeight)
      
      // 重新分配项目,以优化布局
      this.$nextTick(() => {
        this.distributeItems(this.list)
      })
    },
    
    // 图片加载失败事件
    onImageError() {
      console.error('图片加载失败')
    },
    
    // 将宽度值(如'100px')转换为像素数值
    parseWidthToPx(widthValue) {
      if (typeof widthValue === 'string' && widthValue.includes('px')) {
        return parseFloat(widthValue)
      }
      // 如果已经是数字,直接返回
      return Number(widthValue)
    }
  }
}
</script>

<style scoped>
.waterfall-container {
  display: flex;
  flex-direction: row;
  justify-content: flex-start;
  align-items: flex-start;
}

.waterfall-column {
  display: flex;
  flex-direction: column;
}

.waterfall-item {
  background: #fff;
  border-radius: 10rpx;
  overflow: hidden;
  box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.1);
}

.item-title {
  display: block;
  padding: 15rpx;
  font-size: 28rpx;
  font-weight: bold;
}

.item-desc {
  display: block;
  padding: 0 15rpx 15rpx;
  font-size: 24rpx;
  color: #666;
}
</style>

使用示例

<template>
  <view>
    <Waterfall
      :list="goodsList"
      :column-count="columnCount"
      keyName="goodsId"
      imageKey="cover"
      nameKey="goodsName"
      textKey="description"
      gap="20rpx"
      item-margin-bottom="20rpx"
    >
      <!-- 自定义项目内容插槽 -->
      <template #item="{ item }">
        <image 
          :src="item.cover" 
          mode="widthFix" 
          style="width: 100%;"
        ></image>
        <view class="custom-content">
          <text class="title">{{ item.goodsName }}</text>
          <text class="desc">{{ item.description }}</text>
          <view class="tags">
            <text v-for="tag in item.tags" :key="tag" class="tag">{{ tag }}</text>
          </view>
        </view>
      </template>
    </Waterfall>
    
    <!-- 加载更多指示器 -->
    <view class="load-more" v-if="loading">
      <text>加载中...</text>
    </view>
    
    <!-- 列数切换按钮(示例) -->
    <view class="column-switcher">
      <button @click="columnCount = 2">2列</button>
      <button @click="columnCount = 3">3列</button>
    </view>
  </view>
</template>

<script>
import Waterfall from '@/components/Waterfall.vue'

export default {
  components: {
    Waterfall
  },
  data() {
    return {
      goodsList: [],
      loading: false,
      columnCount: 2
    }
  },
  onLoad() {
    this.loadGoodsList()
  },
  onReachBottom() {
    this.loadMore()
  },
  methods: {
    async loadGoodsList() {
      this.loading = true
      // 模拟API调用
      const res = await this.$api.getGoodsList()
      this.goodsList = res.data
      this.loading = false
    },
    
    async loadMore() {
      if (this.loading) return
      
      this.loading = true
      // 模拟加载更多数据
      const res = await this.$api.getGoodsList({ page: this.page + 1 })
      this.goodsList = [...this.goodsList, ...res.data]
      this.loading = false
    }
  }
}
</script>

<style scoped>
.column-switcher {
  position: fixed;
  bottom: 30rpx;
  right: 30rpx;
}

.custom-content {
  padding: 20rpx;
}

.title {
  display: block;
  font-size: 28rpx;
  font-weight: bold;
  margin-bottom: 10rpx;
}

.desc {
  display: block;
  font-size: 24rpx;
  color: #666;
  margin-bottom: 10rpx;
}

.tags {
  display: flex;
  flex-wrap: wrap;
}

.tag {
  background: #f0f0f0;
  padding: 8rpx 16rpx;
  border-radius: 20rpx;
  font-size: 20rpx;
  margin-right: 10rpx;
  margin-bottom: 10rpx;
}

.load-more {
  text-align: center;
  padding: 30rpx;
  color: #999;
}
</style>

实现要点说明

  • 动态列数:通过columnCount属性控制列数,当列数变化时,会重新分配项目到各列。

  • 响应式布局:列宽度根据屏幕宽度和列数动态计算,确保布局适应不同屏幕尺寸。

  • 高度计算:项目高度通过图片加载后的宽高比计算得出,并提供预估高度用于初始布局。

  • 插槽支持:通过插槽允许用户完全自定义项目内容,增加组件的灵活性。

  • 性能考虑:图片加载使用异步方式,避免阻塞主线程。同时,使用$nextTick确保在DOM更新后再重新计算布局。

posted @ 2025-10-16 10:00  網友攃  阅读(103)  评论(0)    收藏  举报