时之幻想曲手游官网版
944.46MB · 2026-02-16
点攒 + 收藏 === 学会???

瀑布流布局(Waterfall Layout)是一种等宽不等高的多列布局方式,视觉上元素像瀑布一样逐列填充。核心原理:
CSS Grid 的 grid-auto-flow: dense 属性可实现密集填充模式,结合动态计算元素高度所占行数,实现近似瀑布流效果。
grid-auto-rows 定义基础行高。<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>grid-template-columns 定义响应式列数,媒体查询动态调整。grid-auto-flow: dense 让元素尽可能紧凑排列,填补空白。grid-auto-rows 设置基础行高,元素通过 grid-row-end 跨越多行。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))}
