?‍? 写在开头

点攒 + 收藏 === 学会???

效果图

瀑布流布局原理

瀑布流布局(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.th*em*o*viedb.org/3/discover/movie?sort_by=popularity.desc&api_key=3fd2be6f0c70a2a598f084ddfb75487c&page='const IMG_PATH = 'https://image.t***mdb.org/t/p/w1280'const SEARCH_API = 'https://api.themovi***edb.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 = 20const GAP = 20const cardsRef = ref<HTMLElement[]>([])// ResizeObserver接口监视Element内容盒或边框盒的变化let observer: ResizeObserverfunction 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: ResizeObserverfunction 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))}

  

本文转载于:https://juej*in.cn**/post/7485998798655438858

如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com