grid实现瀑布流

瀑布流布局原理

瀑布流布局(Waterfall Layout)是一种等宽不等高的多列布局方式,视觉上元素像瀑布一样逐列填充。核心原理:

  1. 等宽多列:将容器划分为多个等宽的列。
  2. 动态填充:元素按顺序优先插入当前高度最短的列,保证布局紧凑。

基于 CSS Grid 的实现思路

CSS Grid 的 grid-auto-flow: dense 属性可实现密集填充模式,结合动态计算元素高度所占行数,实现近似瀑布流效果。

  1. 固定行高:使用 grid-auto-rows 定义基础行高。
  2. 跨行计算:动态计算每个元素需要跨越的行数。
  3. 响应式列数:通过媒体查询动态调整列数,适配不同屏幕尺寸

实现步骤

1. 代码实现

<template>
  <div class="movie-app">
    <header ref="headerRef">
      <div class="header-wrap">
        <h1>Title</h1>
        <div class="input-container">
          <n-input v-model:value="searchInput" round size="large" placeholder="Search"
                   @keyup.enter="searchHandler"></n-input>
        </div>
      </div>
    </header>
    <main>
      <div class="movies-container">
        <transition-group name="fade-bottom">
          <div ref="cardsRef" class="card" v-for="item in movieList" :key="item.id">
            <n-image :src="IMG_PATH+item.poster_path" preview-disabled width="100%" lazy :alt="item.title"/>
            <div class="card-detail">
              <n-h2 class="card-title">{{ item.original_title }}</n-h2>
              <n-tag :bordered="false" :type="getTagType(item.vote_average)">{{ item.vote_average.toFixed(1) }}</n-tag>
            </div>
            <n-p class="card-overview">{{ item.overview }}</n-p>
          </div>
        </transition-group>
      </div>
    </main>
  </div>
</template>
<script setup lang="ts">
import {nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
import {Movie, Result} from "@/components/MovieList/type";

const API_URL = 'https://api.themoviedb.org/3/discover/movie?sort_by=popularity.desc&api_key=3fd2be6f0c70a2a598f084ddfb75487c&page='
const IMG_PATH = 'https://image.tmdb.org/t/p/w1280'
const SEARCH_API = 'https://api.themoviedb.org/3/search/movie?api_key=3fd2be6f0c70a2a598f084ddfb75487c&query='


const movieList = ref<Movie[]>([])
 // 页码
const currentPage = ref(1)
 // 加载状态
const isLoading = ref(false)
// 是否需要触底加载
const isNeedLoadingBottom = ref(true)

async function fetchMovies(page = 1) {
  if (isLoading.value) return
  isLoading.value = true
  try {
    const res = await fetch(API_URL + page)
    const result: Result = await res.json()
    movieList.value.push(...result.results)
    currentPage.value = page
  } catch (error) {
    console.error(error)
  } finally {
    isLoading.value = false
  }
}


// 搜索
const searchInput = ref<string>('')
const searchHandler = async () => {
  if (!searchInput.value) {
    isNeedLoadingBottom.value = true
    movieList.value = []
    await fetchMovies(1)
    // 确保在数据加载后重新初始化瀑布流
    await nextTick(() => initObserve())
  } else {
    isLoading.value = true
    isNeedLoadingBottom.value = false
    try {
      const res = await fetch(SEARCH_API + searchInput.value)
      const result: Result = await res.json()
      movieList.value = result.results
    } catch (error) {
      console.error(error)
    } finally {
      isLoading.value = false
    }
  }
  //滚动到顶部
  window.scrollTo({
    top: 0,
    behavior: 'instant'
  })

}

const getTagType = (vote: number): 'success' | 'warning' | 'error' => {
  if (vote >= 8) {
    return 'success'
  } else if (vote >= 5) {
    return 'warning'
  } else {
    return 'error'
  }

}
const ROW_HEIGHT = 20
const GAP = 20

const cardsRef = ref<HTMLElement[]>([])

// ResizeObserver接口监视Element内容盒或边框盒的变化
let observer: ResizeObserver

function initObserve() {
  observer?.disconnect()
  observer = new ResizeObserver((entries) => {
    entries.forEach(entry => {
      const card = entry.target as HTMLElement
      const height = card.offsetHeight
      //计算(当前卡片的实际高度+gap)/(隐式网格的行高+gap)行跨越网格数
      const span = Math.ceil((height + GAP) / (ROW_HEIGHT + GAP))
      card.style.gridRowEnd = `span ${span}`
    })
  })
  // 观察所有卡片
  cardsRef.value.forEach(card => observer.observe(card))
}

// 触底加载功能
function handleScroll() {
  // 滚动位置
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100 && isNeedLoadingBottom.value) {
    if (!isLoading.value) {
      fetchMovies(currentPage.value + 1).then(() => {
        nextTick(() => {
          // 重新观察所有卡片,包括新添加的
          cardsRef.value.forEach(card => {
            if (!observer.observe) return
            observer.observe(card)
          })
        })
      })
    }
  }
  checkScroll()
}

// 监听movieList变化,确保新元素被观察
watch(movieList, () => {
  nextTick(() => {
    // 确保所有卡片都被观察,包括新添加的
    cardsRef.value.forEach(card => {
      if (!observer || !card) return
      observer.observe(card)
    })
  })
})

onMounted(async () => {
  await fetchMovies()
  await nextTick(() => { 
  // 确保DOM更新完成
    initObserve()
  })
  window.addEventListener('scroll', handleScroll)
})

// 组件卸载时清理
onUnmounted(() => {
  observer?.disconnect()
  window.removeEventListener('scroll', handleScroll)
})


const headerRef = ref<HTMLElement | null>(null)
//处理header粘性效果
function checkScroll() {
  if (window.scrollY > 20) {
    headerRef.value?.classList.add('active')
  } else {
    headerRef.value?.classList.remove('active')
  }
}
</script>
<style scoped lang="scss">
.movie-app {
  width: 100%;
  background: $primary-color;
  min-height: 100vh;

  header {
    position: sticky;
    z-index: 999;
    left: 0;
    top: 0;
    right: 0;
    transition: all .2s ease-in-out;
    padding: 16px;
    width: 100%;
    color: $--color-text-4;

    &.active {
      background-color: $secondary-color;
      box-shadow: $--border-shadow;
    }

    .header-wrap {
      margin: 0 auto;
      @include flex-between;

      .input-container {
        width: 230px;
      }
    }
  }

  main {

    @media screen and (max-width: 1024px) {
      .movies-container {
        grid-template-columns: repeat(3, 1fr) !important;
      }
    }
    @media screen and (max-width: 768px) {
      .movies-container {
        grid-template-columns: repeat(2, 1fr) !important;
      }
    }

    .movies-container {
      padding: 16px;
      //grid实现瀑布流效果
      display: grid;
      //默认是4列
      grid-template-columns: repeat(4, 1fr);
      gap: v-bind('GAP+"px"');
      grid-auto-rows: v-bind('ROW_HEIGHT+"px"');
      //设置网格内容与网格区域的顶端对齐
      align-items: start;
      grid-auto-flow: dense;

      .card {
        width: 100%;
        background: $secondary-color;
        box-shadow: $--border-shadow;
        overflow: hidden;
        border-radius: $--border-radius-base;

        &-detail {
          @include flex-between;
          padding: 8px;
        }

        &-title {
          color: $--color-text-4;
          margin: 0;
          font-size: 20px;
        }

        &-overview {
          color: $--color-text-4;
          font-size: 14px;
          padding: 0 8px 16px;
          margin: 0;
        }
      }
    }
  }
}
</style>

2. 原理解析

  • Grid 容器:通过 grid-template-columns 定义响应式列数,媒体查询动态调整。
  • 密集填充:grid-auto-flow: dense 让元素尽可能紧凑排列,填补空白。
  • 动态行高:grid-auto-rows 设置基础行高,元素通过 grid-row-end 跨越多行。
  • 高度计算:组件挂载时计算每个元素的实际高度,转换为跨越的行数。

3. 动态高度计算

let observer: ResizeObserver

function initObserve() {
  observer?.disconnect()
  observer = new ResizeObserver((entries) => {
    entries.forEach(entry => {
      const card = entry.target as HTMLElement
      const height = card.offsetHeight
      //计算(当前卡片的实际高度+gap)/(隐式网格的行高+gap)行跨越网格数
      const span = Math.ceil((height + GAP) / (ROW_HEIGHT + GAP))
      card.style.gridRowEnd = `span ${span}`
    })
  })
  // 观察所有卡片
  cardsRef.value.forEach(card => observer.observe(card))
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值