普通瀑布流(Javascript)

53 阅读2分钟
  <div class="waterfall-container" ref="container">
    <div
      v-for="(item, index) in items"
      :key="item.id"
      class="waterfall-item"
      :style="item.style"
    >
      <div class="image-container">
        <img
          :src="item.image"
          :alt="item.title"
          ref="imageEls"
          @load="onImageLoad(index)"
          @error="onImageError(index)"
          :style="{ opacity: item.loaded ? 1 : 0 }"
        />
        <div class="image-loading" v-if="!item.loaded">图片加载中...</div>
      </div>
      <div class="content" v-if="item.loaded">
        <h3>{{ item.title }}</h3>
        <p>{{ item.description }}</p>
      </div>
    </div>
    <div class="loading" v-if="loading">加载更多...</div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, nextTick } from "vue";

const container = ref(null);
const imageEls = ref([]);

// 配置项
const config = ref({
  columnCount: 3,
  gap: 15,
  responsive: {
    640: 1,
    768: 2,
    1024: 3,
    1440: 4,
  },
  maxImageHeight: 300, // 图片最大高度
  contentMinHeight: 80, // 内容最小高度
});

const items = ref([]);
const columnHeights = ref([]);
const loading = ref(false);

// 生成更合理的假数据
function generateMockData(count = 10) {
  const mockTitles = [
    "山水风光",
    "城市夜景",
    "自然奇观",
    "野生动物",
    "美食摄影",
    "旅行记录",
    "人文纪实",
    "建筑艺术",
  ];

  const mockDescriptions = [
    "壮丽的自然景色",
    "城市的光影交错",
    "大自然的鬼斧神工",
    "野生动物的精彩瞬间",
    "令人垂涎的美食",
    "旅途中的美好回忆",
  ];

  // 使用更稳定的随机图片尺寸
  const imageHeights = [300, 350, 400, 450, 500];

  return Array.from({ length: count }, (_, i) => {
    const height =
      imageHeights[Math.floor(Math.random() * imageHeights.length)];
    return {
      id: `item-${Date.now()}-${i}`,
      title: mockTitles[i % mockTitles.length],
      description: mockDescriptions[i % mockDescriptions.length],
      image: `https://picsum.photos/id/${10 + i}/300/${height}`,
      style: { opacity: 0 }, // 初始不可见
      loaded: false,
      height: 0,
    };
  });
}

// 初始化数据
function initItems() {
  items.value = generateMockData(20);
}

// 响应式调整列数
function updateColumnCount() {
  if (!container.value) return;

  const width = window.innerWidth;
  let newColumnCount = 1;

  Object.entries(config.value.responsive)
    .sort((a, b) => a[0] - b[0])
    .forEach(([breakpoint, count]) => {
      if (width >= parseInt(breakpoint)) {
        newColumnCount = count;
      }
    });

  if (newColumnCount !== config.value.columnCount) {
    config.value.columnCount = newColumnCount;
    calculateLayout();
  }
}

// 图片加载成功
function onImageLoad(index) {
  const img = imageEls.value[index];
  if (!img) return;

  nextTick(() => {
    const itemWidth = getItemWidth();
    const aspectRatio = img.naturalHeight / img.naturalWidth;

    // 计算图片高度(不超过最大高度)
    let imageHeight = aspectRatio * itemWidth;
    if (imageHeight > config.value.maxImageHeight) {
      imageHeight = config.value.maxImageHeight;
    }

    // 总高度 = 图片高度 + 内容最小高度
    const totalHeight = imageHeight + config.value.contentMinHeight;

    items.value[index].loaded = true;
    items.value[index].height = totalHeight;
    items.value[index].style.opacity = 1;

    calculateLayout();
  });
}

// 图片加载失败处理
function onImageError(index) {
  items.value[index].image = "https://placehold.co/300x200?text=Image+Error";
  items.value[index].loaded = true;
  items.value[index].height = 200 + config.value.contentMinHeight; // 默认高度
  items.value[index].style.opacity = 1;

  calculateLayout();
}

// 获取每项宽度
function getItemWidth() {
  if (!container.value) return 0;
  return (
    (container.value.offsetWidth -
      config.value.gap * (config.value.columnCount - 1)) /
    config.value.columnCount
  );
}

// 计算布局
function calculateLayout() {
  if (!container.value) return;

  const itemWidth = getItemWidth();
  columnHeights.value = new Array(config.value.columnCount).fill(0);

  items.value.forEach((item) => {
    if (!item.loaded) return;

    const shortestColumnIndex = columnHeights.value.indexOf(
      Math.min(...columnHeights.value)
    );

    item.style = {
      ...item.style,
      width: `${itemWidth}px`,
      height: `${item.height}px`,
      transform: `translate(${
        shortestColumnIndex * (itemWidth + config.value.gap)
      }px, ${columnHeights.value[shortestColumnIndex]}px)`,
      position: "absolute",
      top: "0",
      left: "0",
      transition: "transform 0.3s ease, opacity 0.3s ease",
    };

    columnHeights.value[shortestColumnIndex] += item.height + config.value.gap;
  });

  container.value.style.height = `${Math.max(...columnHeights.value)}px`;
}

// 加载更多数据
async function loadMore() {
  if (loading.value) return;

  loading.value = true;
  await new Promise((resolve) => setTimeout(resolve, 800)); // 模拟网络延迟

  const newItems = generateMockData(10);
  items.value = [...items.value, ...newItems];

  // 等待DOM更新后重新计算布局
  nextTick(() => {
    loading.value = false;
  });
}

// 滚动加载检测
function handleScroll() {
  if (loading.value || !container.value) return;

  const { scrollTop, clientHeight } = document.documentElement;
  const containerBottom =
    container.value.offsetTop + container.value.offsetHeight;

  if (scrollTop + clientHeight >= containerBottom - 100) {
    loadMore();
  }
}

// 初始化
onMounted(() => {
  initItems();
  updateColumnCount();
  window.addEventListener("resize", updateColumnCount);
  window.addEventListener("scroll", handleScroll);
});

onUnmounted(() => {
  window.removeEventListener("resize", updateColumnCount);
  window.removeEventListener("scroll", handleScroll);
});
</script>

<style scoped>
.waterfall-container {
  position: relative;
  width: 100%;
  margin: 0 auto;
  padding: 0 15px;
  box-sizing: border-box;
  min-height: 100vh;
}

.waterfall-item {
  box-sizing: border-box;
  background: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  transition: transform 0.3s ease, opacity 0.3s ease;
  display: flex;
  flex-direction: column;
}

.image-container {
  position: relative;
  width: 100%;
  overflow: hidden;
}

.waterfall-item img {
  width: 100%;
  height: auto;
  max-height: 300px;
  display: block;
  object-fit: cover;
  transition: opacity 0.3s ease;
}

.image-loading {
  height: 200px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #999;
  background: #f5f5f5;
  border-radius: 4px;
}

.content {
  padding: 12px;
  flex: 1;
  display: flex;
  flex-direction: column;
  min-height: 80px;
}

.content h3 {
  margin: 0 0 8px 0;
  font-size: 16px;
  color: #333;
  line-height: 1.3;
}

.content p {
  margin: 0;
  font-size: 14px;
  color: #666;
  line-height: 1.4;
  flex: 1;
}

.loading {
  text-align: center;
  padding: 20px;
  color: #999;
  font-size: 14px;
}
</style>