一、瀑布流布局的实现原理
1、确定一行容纳几列元素
2、寻找各列所有元素高度之和的最小者、并将新元素添加到该列上
3、继续寻找各列所有元素高度之和的最小者,继续将新元素添加到该列上,直到所有元素排列完成
二、demo分析
示例中,我们明确几个需求:
1、实现一个瀑布流列表、列表具有下拉刷新、上拉加载下一页功能
1、瀑布流一行容纳两列元素
2、元素可以随着窗口的改变去自适应
三、vue组件设计:
1、demo列表组件:我们可以把业务需求封装成一个组件、使用vant下拉刷新和上拉加载ui组件完成、具体细节根据具体业务
2、瀑布流列表组件:把瀑布流两列布局的逻辑用一个组件完成
瀑布流布局实现难点第一个就是计算各个子项的位置,在css里我们把包裹子项的父元素position:relative,子项的高度根据子项的内容自适应、那么如何确定子项的高度呢?有两种方式
- 最常见的是:一般大多数瀑布流是有封面图+标题+简介,封面图是异步加载的,可以通过监听封面图加载完成的onload事件获取封面图的高度,然后得到子项元素的高度。
- 服务端可以直接返回图片的宽高,前端直接拿到封面图宽高,这样做的好处是可以避免列表滑动过快,页面偶尔出现大片空白,demo采用的是第2种方式
第二个难点是上拉加载瀑布流数据需要保证上拉加载下一页数据、在处理下一页数据的位置布局渲染时、之前已经渲染过的位置布局不被重新渲染、如果保证呢?我们需要找到数据列表中下一页数据前的最后一条数据
<template>
<ul class="feed-list" :style="{ height: `${containerHeight}px` }">
<li v-for="(item, index) in data" :key="item.id + '-' + index">
<feed-item :data="item" @on-click="onClick"/>
</li>
</ul>
</template>
<script>
import FeedItem from './feed-item'
export default {
name: 'FeedList',
props: { data: Array },
components: { FeedItem },
data () {
return {
leftColHeight: 0,
rightColHeight: 0,
lastRepositionItem: null,
resizeTimer: null,
resizeDelay: 200,
repositionStartIndex: 0
}
},
computed: {
hasData () {
return this.data && this.data.length > 0
},
lastItem () {
if (this.hasData) {
return this.data[this.data.length - 1] }
else { return null }
},
containerHeight () {
return Math.max(this.leftColHeight, this.rightColHeight)
}
},
created () {
this.repositionIfNeed()
this.setUpPageResizeListener()
},
watch: {
data () {
this.repositionIfNeed()
}
},
methods: {
repositionIfNeed () {
if (this.hasData) {
// 目的是找到该从第几个元素开始定位
// 如果是翻页,数据量增加,那从上一次定位元素的下一个开始
// 如果数据全变了,比如切换tab, 则从头开始定位
this.repositionStartIndex = this.data.findIndex(item => item === this.lastRepositionItem) + 1
if (this.repositionStartIndex === 0) {
this.clearHeight()
}
this.$nextTick(this.reposition)
} else {
this.onNoData()
}
},
reposition () {
const container = this.$el
const children = [...container.children]
const elementsNeedReposition = children.slice(this.repositionStartIndex)
for (const elem of elementsNeedReposition) {
const totalHeight = this.getItemHeight(elem)
if (this.leftColHeight <= this.rightColHeight) {
this.appendToLeftCol(elem)
this.leftColHeight += totalHeight
} else {
this.appendToRightCol(elem)
this.rightColHeight += totalHeight
}
}
this.lastRepositionItem = this.lastItem
this.$nextTick(this.onRepositionComplete) },
getItemHeight (elem) {
const height = elem.offsetHeight
const marginTop = parseFloat(getComputedStyle(elem).marginTop)
const totalHeight = height + marginTop
return totalHeight
},
appendToLeftCol (elem) {
elem.style.left = 0
elem.style.right = 'initial'
elem.style.top = `${this.leftColHeight}px`
elem.style.opacity = '1'
},
appendToRightCol (elem) {
elem.style.right = 0
elem.style.left = 'initial'
elem.style.top = `${this.rightColHeight}px`
elem.style.opacity = '1'
},
onRepositionComplete () {
this.$emit('complete')
},
setUpPageResizeListener () {
window.addEventListener('resize', this.debouncedHandler)
},
debouncedHandler () {
clearTimeout(this.resizeTimer)
this.resizeTimer = setTimeout(this.onResize, this.resizeDelay)
},
onResize () {
this.lastRepositionItem = null
this.clearHeight()
this.repositionIfNeed()
},
onNoData () {
this.lastRepositionItem = null
this.clearHeight()
},
clearHeight () {
this.leftColHeight = 0
this.rightColHeight = 0
},
onClick (item) {
const { type } = item
}
}}
</script>
<style lang="less">
.feed-list{
position: relative;
>li{
position: absolute;
width: calc(50% - 5px);
opacity: 0;
&:nth-child(n + 3) {
margin-top: 10px;
}
}
}
</style>
3、组成瀑布流列表子项组件-处理单个子项的业务逻辑
<template>
<div class="feed-item" @click="onClick">
<div class="top">
<div class="img" v-lazy:background-image="data.url" :style="imageSize"></div>
</div>
<div class="info">
<div class="name">{{ name }}</div>
<div class="desc" v-if="simpleTitle">{{ simpleTitle }}</div>
</div>
</div>
</template>
<script>
/** * @description 获取url参数 */
function getUrlParam (name, url = location.search) {
const urlReg = new RegExp(`(?:&|\\?)${name}=(.*?)(?=&|/|#|$)`, 'g')
const res = []
let match = urlReg.exec(url)
while (match) {
res.push(decodeURIComponent(match[1]))
match = urlReg.exec(url)
}
// 如果有多个结果返回数组,否则直接返回获取的字符串
return res.length > 1 ? res : res[0]
}
// 图片最小/大 高/宽比
const minHeightWidthRatio = 1
const maxHeightWidthRatio = 1.5
export default {
name: 'FeedItem',
props: { data: Object },
computed: {
img () { return this.data.img },
name () { return this.data.name },
// 副标题
simpleTitle () { return this.data.simpleTitle },
imageSize () {
let width = 0
let height = 0
if (this.img) {
width = getUrlParam('y-img-width', this.img)
height = getUrlParam('y-img-height', this.img)
} else {
width = 100
height = 100
}
const ratio = this.getHeightWidthRatio({ height, width })
return {
paddingBottom: `${(ratio * 100).toFixed(2)}%`
}
}
},
methods: {
onClick () {
this.$emit('on-click', this.data)
},
getHeightWidthRatio ({ height, width }) {
let ratio = height / width
ratio = Math.max(ratio, minHeightWidthRatio)
ratio = Math.min(ratio, maxHeightWidthRatio)
return ratio
}
}}
</script>
<style lang="less">
@import '~@/styles/mixins.less';
@import '~@/styles/variables.less';
.feed-item{
background:#fff;
border-radius:8px;
overflow: hidden;
.lazyload-placeholder();
.img{
background-size: cover;
background-position: center;
}
}
</style>