介绍
按照元素高度自适应排列,形成参差不齐的多列布局。
代码演示
基础使用
瀑布流布局是使用绝对定位实现的,每一项内容需放置在 WaterfallItem 组件里面以便控制其位置;可放置任意内容,并不限定为具体模板。
如果里面包含图片或其他使其在挂载后仍然不确定高度的组件时,需在加载完后调用瀑布流项的 load 插槽中的 onLoad 回调,以便计算其渲染后的高度并进行准确定位。
瀑布流会按照渲染的顺序逐一将加载好的瀑布流项渲染出来;如果后面比前面要先加载完,也得等前面加载完才能渲染。因此,如果前面有一张很大的图片需要很久才能加载完,则会堵塞后面的渲染。
因此,一次性不要加载太多图片,图片的尺寸也要有所限制。
瀑布流组件的 load 事件会在所有项都加载完后触发,可以用来处理加载状态。
<template>
<doc-page title="基础使用">
<s-waterfall class="mx-4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<SimulatedImage :meta="item.img" @load="onLoad" />
<div class="mt-2">{{ item.title }}</div>
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="ts">
import { random, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
import SimulatedImage from './SimulatedImage.vue'
import { longText } from '@/data/text'
interface ListItem {
title: string
img: {
width: number
height: number
}
}
const list = ref<ListItem[]>([])
const getData = () => {
return new Promise<ListItem[]>((resolve) => {
const data = Array(20)
.fill(0)
.map(() => {
const min = 20
const max = 50
const startIndex = random(0, longText.length - max)
const length = random(min, max)
return {
title: longText.slice(startIndex, startIndex + length),
img: {
width: random(100, 500),
height: random(100, 500),
},
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...(await getData()))
})
</script><template>
<doc-page title="基础使用">
<s-waterfall class="mx-4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<SimulatedImage :meta="item.img" @load="onLoad" />
<div class="mt-2">{{ item.title }}</div>
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="js">
import { random, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
import SimulatedImage from './SimulatedImage.vue'
import { longText } from '@/data/text'
const list = ref([])
const getData = () => {
return new Promise((resolve) => {
const data = Array(20)
.fill(0)
.map(() => {
const min = 20
const max = 50
const startIndex = random(0, longText.length - max)
const length = random(min, max)
return {
title: longText.slice(startIndex, startIndex + length),
img: {
width: random(100, 500),
height: random(100, 500),
},
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...(await getData()))
})
</script>SimulatedImage.vue
<template>
<div class="relative box-border" :style="{ paddingTop }">
<div class="absolute inset-0 flex justify-center items-center bg-(--s-fill-color)">
<span>{{ meta.width }}</span>
<span>x</span>
<span>{{ meta.height }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { random, useTimeout } from 'sard'
import { computed, onMounted, ref } from 'vue'
const props = defineProps<{
meta: {
width: number
height: number
}
}>()
const emit = defineEmits<{
(
e: 'load',
event: {
target: {
naturalWidth: number
naturalHeight: number
}
},
): void
}>()
const internalWidth = ref(320)
const internalHeight = ref(240)
const currWidth = computed(() => internalWidth.value)
const currHeight = computed(() => internalHeight.value)
const paddingTop = computed(() => (currHeight.value / currWidth.value) * 100 + '%')
const sizeTimer = useTimeout()
onMounted(() => {
sizeTimer.set(
() => {
internalWidth.value = props.meta.width
internalHeight.value = props.meta.height
emit('load', {
target: {
naturalWidth: props.meta.width,
naturalHeight: props.meta.height,
},
})
},
random(150, 1500),
)
})
</script><template>
<div class="relative box-border" :style="{ paddingTop }">
<div class="absolute inset-0 flex justify-center items-center bg-(--s-fill-color)">
<span>{{ meta.width }}</span>
<span>x</span>
<span>{{ meta.height }}</span>
</div>
</div>
</template>
<script setup lang="js">
import { random, useTimeout } from 'sard'
import { computed, onMounted, ref } from 'vue'
const props = defineProps()
const emit = defineEmits()
const internalWidth = ref(320)
const internalHeight = ref(240)
const currWidth = computed(() => internalWidth.value)
const currHeight = computed(() => internalHeight.value)
const paddingTop = computed(() => (currHeight.value / currWidth.value) * 100 + '%')
const sizeTimer = useTimeout()
onMounted(() => {
sizeTimer.set(
() => {
internalWidth.value = props.meta.width
internalHeight.value = props.meta.height
emit('load', {
target: {
naturalWidth: props.meta.width,
naturalHeight: props.meta.height,
},
})
},
random(150, 1500),
)
})
</script>已知宽高
如果接口已将图片宽高信息返回,可以使用 WaterfallLoad 组件处理 onLoad 回调,会在挂载时调用,无需等待图片加载完即可渲染,可极大提高用户体验。
WaterfallLoad 会根据 width、height 属性计算占用的空间。
<template>
<doc-page title="已知宽高">
<s-waterfall class="mx-4">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<s-waterfall-load :width="item.img.width" :height="item.img.height" @load="onLoad">
<SimulatedImage class="w-full h-full pt-0" :meta="item.img" />
</s-waterfall-load>
<div class="mt-2">{{ item.title }}</div>
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="ts">
import { random } from 'sard'
import { onMounted, ref } from 'vue'
import SimulatedImage from './SimulatedImage.vue'
import { longText } from '@/data/text'
interface ListItem {
title: string
img: {
width: number
height: number
}
}
const list = ref<ListItem[]>([])
const getData = () => {
return new Promise<ListItem[]>((resolve) => {
const data = Array(20)
.fill(0)
.map(() => {
const min = 20
const max = 50
const startIndex = random(0, longText.length - max)
const length = random(min, max)
return {
title: longText.slice(startIndex, startIndex + length),
img: {
width: random(100, 500),
height: random(100, 500),
},
}
})
resolve(data)
})
}
onMounted(async () => {
list.value.push(...(await getData()))
})
</script><template>
<doc-page title="已知宽高">
<s-waterfall class="mx-4">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<s-waterfall-load :width="item.img.width" :height="item.img.height" @load="onLoad">
<SimulatedImage class="w-full h-full pt-0" :meta="item.img" />
</s-waterfall-load>
<div class="mt-2">{{ item.title }}</div>
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="js">
import { random } from 'sard'
import { onMounted, ref } from 'vue'
import SimulatedImage from './SimulatedImage.vue'
import { longText } from '@/data/text'
const list = ref([])
const getData = () => {
return new Promise((resolve) => {
const data = Array(20)
.fill(0)
.map(() => {
const min = 20
const max = 50
const startIndex = random(0, longText.length - max)
const length = random(min, max)
return {
title: longText.slice(startIndex, startIndex + length),
img: {
width: random(100, 500),
height: random(100, 500),
},
}
})
resolve(data)
})
}
onMounted(async () => {
list.value.push(...(await getData()))
})
</script>真实案例
需同时监听 image 组件的 load 和 error 事件,确保 onLoad 回调函数能调用,无论图片加载成功或失败。
<template>
<doc-page title="真实案例">
<s-waterfall class="mx-4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<img mode="widthFix" class="flex w-full" :src="item.url" @load="onLoad" @error="onLoad" />
<div class="mt-2 text-base">{{ item.title }}</div>
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="ts">
import { longText } from '@/data/text'
import { random, shuffle, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
interface ListItem {
title: string
url: string
}
const list = ref<ListItem[]>([])
const getData = () => {
return new Promise<ListItem[]>((resolve) => {
const data = Array(20)
.fill(0)
.map((_, i) => {
const min = 20
const max = 50
const startIndex = random(0, longText.length - max)
const length = random(min, max)
return {
title: longText.slice(startIndex, startIndex + length),
url: `https://fastly.jsdelivr.net/npm/@sard/assets/images/cat${(i % 12) + 1}.jpg`,
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...shuffle(await getData()))
})
</script><template>
<doc-page title="真实案例">
<s-waterfall class="mx-4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<img mode="widthFix" class="flex w-full" :src="item.url" @load="onLoad" @error="onLoad" />
<div class="mt-2 text-base">{{ item.title }}</div>
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="js">
import { longText } from '@/data/text'
import { random, shuffle, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
const list = ref([])
const getData = () => {
return new Promise((resolve) => {
const data = Array(20)
.fill(0)
.map((_, i) => {
const min = 20
const max = 50
const startIndex = random(0, longText.length - max)
const length = random(min, max)
return {
title: longText.slice(startIndex, startIndex + length),
url: `https://fastly.jsdelivr.net/npm/@sard/assets/images/cat${(i % 12) + 1}.jpg`,
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...shuffle(await getData()))
})
</script>最大等待时间
为了避免因图片太大导致渲染阻塞,WaterfallLoad 组件提供了 max-wait 属性,在超过等待时间后取自定义的宽高进行渲染,无需等待图片加载完;加载时间小于等待时间时,则取图片实际的宽高进行渲染。
宽高只用于计算比例来进行等比缩放,而不是最终的展示尺寸。
<template>
<doc-page title="最大等待时间">
<s-waterfall class="mx-4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<s-waterfall-load
:width="100"
:height="100"
:max-wait="150"
@load="onLoad"
#default="{ onLoad, overtime }"
>
<img
class="flex w-full h-full object-cover"
:src="item.url"
@load="onLoad"
@error="onLoad"
/>
<s-tag
v-if="overtime"
color="danger"
mark="right"
variant="solid"
class="absolute top-0 left-0"
>
超时
</s-tag>
</s-waterfall-load>
<div class="mt-2 text-base">{{ item.title }}</div>
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="ts">
import { longText } from '@/data/text'
import { random, shuffle, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
interface ListItem {
title: string
url: string
}
const list = ref<ListItem[]>([])
const getData = () => {
return new Promise<ListItem[]>((resolve) => {
const data = Array(20)
.fill(0)
.map((_, i) => {
const min = 20
const max = 50
const startIndex = random(0, longText.length - max)
const length = random(min, max)
return {
title: longText.slice(startIndex, startIndex + length),
url: `https://fastly.jsdelivr.net/npm/@sard/assets/images/cat${(i % 12) + 1}.jpg`,
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...shuffle(await getData()))
})
</script><template>
<doc-page title="最大等待时间">
<s-waterfall class="mx-4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<s-waterfall-load
:width="100"
:height="100"
:max-wait="150"
@load="onLoad"
#default="{ onLoad, overtime }"
>
<img
class="flex w-full h-full object-cover"
:src="item.url"
@load="onLoad"
@error="onLoad"
/>
<s-tag
v-if="overtime"
color="danger"
mark="right"
variant="solid"
class="absolute top-0 left-0"
>
超时
</s-tag>
</s-waterfall-load>
<div class="mt-2 text-base">{{ item.title }}</div>
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="js">
import { longText } from '@/data/text'
import { random, shuffle, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
const list = ref([])
const getData = () => {
return new Promise((resolve) => {
const data = Array(20)
.fill(0)
.map((_, i) => {
const min = 20
const max = 50
const startIndex = random(0, longText.length - max)
const length = random(min, max)
return {
title: longText.slice(startIndex, startIndex + length),
url: `https://fastly.jsdelivr.net/npm/@sard/assets/images/cat${(i % 12) + 1}.jpg`,
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...shuffle(await getData()))
})
</script>大图
可使用 column-gap 和 row-gap 设置瀑布流项的间隔大小,单位为 px。
<template>
<doc-page title="大图">
<s-waterfall :column-gap="4" :row-gap="4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<img mode="widthFix" class="flex w-full" :src="item.url" @load="onLoad" @error="onLoad" />
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="ts">
import { shuffle, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
interface ListItem {
url: string
}
const list = ref<ListItem[]>([])
const getData = () => {
return new Promise<ListItem[]>((resolve) => {
const data = Array(19)
.fill(0)
.map((_, i) => {
return {
url: `https://fastly.jsdelivr.net/npm/@sard/assets/images/tiger${(i % 12) + 1}.jpg`,
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...shuffle(await getData()))
})
</script><template>
<doc-page title="大图">
<s-waterfall :column-gap="4" :row-gap="4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<img mode="widthFix" class="flex w-full" :src="item.url" @load="onLoad" @error="onLoad" />
</s-waterfall-item>
</s-waterfall>
</doc-page>
</template>
<script setup lang="js">
import { shuffle, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
const list = ref([])
const getData = () => {
return new Promise((resolve) => {
const data = Array(19)
.fill(0)
.map((_, i) => {
return {
url: `https://fastly.jsdelivr.net/npm/@sard/assets/images/tiger${(i % 12) + 1}.jpg`,
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...shuffle(await getData()))
})
</script>自定义列数
可使用 columns 属性设置任意的列数。
<template>
<doc-page title="自定义列数">
<div class="mx-4">
<s-slider v-model="columns" class="7" show-scale :min="1" :max="8" />
<s-waterfall class="mt-4" :columns="columns" :column-gap="4" :row-gap="4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<img mode="widthFix" class="flex w-full" :src="item.url" @load="onLoad" @error="onLoad" />
</s-waterfall-item>
</s-waterfall>
</div>
</doc-page>
</template>
<script setup lang="ts">
import { shuffle, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
const columns = ref(3)
interface ListItem {
url: string
}
const list = ref<ListItem[]>([])
const getData = () => {
return new Promise<ListItem[]>((resolve) => {
const data = Array(30)
.fill(0)
.map((_, i) => {
return {
url: `https://fastly.jsdelivr.net/npm/@sard/assets/images/tiger${(i % 12) + 1}.jpg`,
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...shuffle(await getData()))
})
</script><template>
<doc-page title="自定义列数">
<div class="mx-4">
<s-slider v-model="columns" class="7" show-scale :min="1" :max="8" />
<s-waterfall class="mt-4" :columns="columns" :column-gap="4" :row-gap="4" @load="onLoad">
<s-waterfall-item v-for="(item, index) in list" :key="index" #default="{ onLoad }">
<img mode="widthFix" class="flex w-full" :src="item.url" @load="onLoad" @error="onLoad" />
</s-waterfall-item>
</s-waterfall>
</div>
</doc-page>
</template>
<script setup lang="js">
import { shuffle, toast } from 'sard'
import { nextTick, onMounted, ref } from 'vue'
const columns = ref(3)
const list = ref([])
const getData = () => {
return new Promise((resolve) => {
const data = Array(30)
.fill(0)
.map((_, i) => {
return {
url: `https://fastly.jsdelivr.net/npm/@sard/assets/images/tiger${(i % 12) + 1}.jpg`,
}
})
resolve(data)
})
}
const onLoad = () => {
toast.hide()
}
onMounted(async () => {
nextTick(() => {
toast.loading('加载中')
})
list.value.push(...shuffle(await getData()))
})
</script>结合下拉刷新与触底加载
可配合 LoadMore 和 PullDownRefresh 组件实现数据动态增减。
因瀑布流在数据加载完后仍需等待图片加载,因此提供了 onLoad 方法,传递到此方法中的函数会在图片加载完后调用,可用于取消加载状态。
<template>
<doc-page title="结合下拉刷新与触底加载">
<s-infinite-list refreshable :request="loadImages">
<s-waterfall ref="waterfallRef" class="mx-4" :column-gap="4" :row-gap="4">
<s-waterfall-item v-for="item in listData" :key="item.id" #default="{ onLoad }">
<img
mode="widthFix"
class="flex w-full"
draggable="false"
:src="item.url"
@load="onLoad"
@error="onLoad"
/>
<s-button
class="absolute! top-2 right-2"
size="small"
color="danger"
@click="onDelete(item)"
>
删除
</s-button>
</s-waterfall-item>
</s-waterfall>
</s-infinite-list>
</doc-page>
</template>
<script setup lang="ts">
import { getImages } from '@/api'
import { ref } from 'vue'
interface ListItem {
id: number
url: string
}
const listData = ref<ListItem[]>([])
const loadImages = async (page: number, isRefresh: boolean) => {
return getImages({ page }).then(({ list, total }) => {
if (isRefresh) {
listData.value = [...list]
} else {
listData.value = [...listData.value, ...list]
}
return listData.value.length >= total || list.length === 0
})
}
// 删除
const onDelete = (item: ListItem) => {
listData.value.splice(listData.value.indexOf(item), 1)
}
</script><template>
<doc-page title="结合下拉刷新与触底加载">
<s-infinite-list refreshable :request="loadImages">
<s-waterfall ref="waterfallRef" class="mx-4" :column-gap="4" :row-gap="4">
<s-waterfall-item v-for="item in listData" :key="item.id" #default="{ onLoad }">
<img
mode="widthFix"
class="flex w-full"
draggable="false"
:src="item.url"
@load="onLoad"
@error="onLoad"
/>
<s-button
class="absolute! top-2 right-2"
size="small"
color="danger"
@click="onDelete(item)"
>
删除
</s-button>
</s-waterfall-item>
</s-waterfall>
</s-infinite-list>
</doc-page>
</template>
<script setup lang="js">
import { getImages } from '@/api'
import { ref } from 'vue'
const listData = ref([])
const loadImages = async (page, isRefresh) => {
return getImages({ page }).then(({ list, total }) => {
if (isRefresh) {
listData.value = [...list]
} else {
listData.value = [...listData.value, ...list]
}
return listData.value.length >= total || list.length === 0
})
}
// 删除
const onDelete = (item) => {
listData.value.splice(listData.value.indexOf(item), 1)
}
</script>API
WaterfallProps
| 属性 | 描述 | 类型 | 默认值 |
|---|---|---|---|
| columns | 自定义列数 | number | 2 |
| column-gap | 列间距,单位px | number | 16 |
| row-gap | 行间距,单位px | number | 16 |
WaterfallSlots
| 插槽 | 描述 | 属性 |
|---|---|---|
| default | 自定义默认内容 | - |
WaterfallEmits
| 事件 | 描述 | 类型 |
|---|---|---|
| load | 所有瀑布流项加载完时触发 | () => void |
| loadstart | 瀑布流项开始加载时触发 | () => void |
WaterfallExpose
| 属性 | 描述 | 类型 |
|---|---|---|
| reflow | 重新排版 | () => void |
| onLoad | 添加回调,会在所有项加载完时调用 | (handler: () => void) => void |
WaterfallItemProps
| 属性 | 描述 | 类型 | 默认值 |
|---|
WaterfallItemSlots
| 插槽 | 描述 | 属性 |
|---|---|---|
| default | 自定义默认内容 | { onLoad: () => void; columnWidth: number } |
default 插槽属性说明:
onLoad: 图片加载完后(无论成功或失败)需要调用此方法。columnWidth: 列宽。
WaterfallLoadProps
| 属性 | 描述 | 类型 | 默认值 |
|---|---|---|---|
| max-wait | 最大等待时间,单位ms | number | 0 |
| width | 自定义宽度 | number | 320 |
| height | 自定义高度 | number | 240 |
WaterfallLoadSlots
| 插槽 | 描述 | 属性 |
|---|---|---|
| default | 自定义默认内容 | { onLoad: (event: Event) => void; overtime: boolean } |
default 插槽属性说明:
onLoad: 图片加载完后(成功和失败)可以调用此方法,加载完时间比max-wait要大则超时。overtime: 是否超时。
WaterfallLoadEmits
| 事件 | 描述 | 类型 |
|---|---|---|
| load | 加载完时触发,无论是正常加载完,还是超时 | () => void |