🧑 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
效果图
瀑布流布局原理
瀑布流布局(Waterfall Layout)是一种等宽不等高的多列布局方式,视觉上元素像瀑布一样逐列填充。核心原理:
- 等宽多列:将容器划分为多个等宽的列。
- 动态填充:元素按顺序优先插入当前高度最短的列,保证布局紧凑。
基于 CSS Grid 的实现思路
CSS Grid 的 grid-auto-flow: dense
属性可实现密集填充模式,结合动态计算元素高度所占行数,实现近似瀑布流效果。
- 固定行高:使用
grid-auto-rows
定义基础行高。 - 跨行计算:动态计算每个元素需要跨越的行数。
- 响应式列数:通过媒体查询动态调整列数,适配不同屏幕尺寸
实现步骤
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)) }
本文转载于:https://juejin.cn/post/7485998798655438858
如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。
来源链接:https://www.cnblogs.com/smileZAZ/p/18964131
© 版权声明
本站所有资源来自于网络,仅供学习与参考,请勿用于商业用途,否则产生的一切后果将由您(转载者)自己承担!
如有侵犯您的版权,请及时联系3500663466#qq.com(#换@),我们将第一时间删除本站数据。
如有侵犯您的版权,请及时联系3500663466#qq.com(#换@),我们将第一时间删除本站数据。
THE END
暂无评论内容