瀑布流布局原理
瀑布流布局(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))
}