一、前言
hello~大家好!上周某一天群里突然消息猛增,点开一看,原来是一位老哥在开发中需要实现一种类似蛇形(也有叫弓形)布局的效果,详情请看下图:
二、应用场景
仔细想想,这类布局其实在业务需求中是挺常见的,我总结了以下几个业务场景
- 抽奖
-
签到
-
步骤条
在群里给那位老哥提供思路和资料的同时,我自己也发现了不少问题。尽管网上其他作者都成功展示了该布局的独特视觉效果,但各自的实现方案存在一定的局限性。比如,有的方案仅适用于固定数量的行数或固定每行元素个数,无法灵活应对多变的动态场景。
考虑到实际应用中页面内容和布局需求可能频繁变动的情况,我决定亲自探索一种更为通用且适应性强的方法,实现真正意义上的动态蛇形布局效果,以克服上述提到的各种限制。
接下来,我将分享我的实践过程与具体实现代码,旨在提供一个无论在任何复杂场景下都能保持流畅、灵活布局表现的解决方案,希望这能对您的工作带来实质性的帮助和启示~
三、思路
- 根据页面宽度变化求出每行元素个数和行数
- 奇数行的元素顺序排列
- 偶数行的元素逆序排列
四、温馨提示:
如果图中的gif动图无法看清,你可以右键点击gif图片,选择在新标签页打开,即可清晰查看。
五、实现步骤
1. 渲染元素
<template>
<div ref="containerRef" class="container">
<div v-for="(item, index) in processedItemList" :key="index" class="item">
{{ item }}
</div>
</div>
<p>行数: {{ lineCount }},每行{{ itemsPerRow }}个</p>
</template>
<script setup>
import { ref, toRef } from "vue";
const containerRef = ref(null);
// 原始数据
const itemList = ref([...Array(20).keys()]);
// 渲染数据
let processedItemList = toRef(itemList.value);
// 行数
const lineCount = ref(0);
// 每行item数量
let itemsPerRow = ref(0);
</script>
<style>
.container {
display: flex;
flex-wrap: wrap;
}
.item {
width: 100px;
height: 100px;
margin: 5px;
background-color: #3b4eff;
display: flex;
color: white;
justify-content: center;
align-items: center;
}
</style>
我们给flex布局中的容器container设置了属性:flex-wrap:wrap,
flex-wrap
默认情况下,项目都排在一条线(又称“轴线”)上。flex-wrap属性定义,flex布局中默认是不换行的。
- nowrap:默认值,不换行;
- wrap:换行
更多关于flex布局的内容和其他属性知识你可以在mdn官网学习
这样我们就实现了宽度不足时,元素自动换行的效果,请看下图:
2.计算行数和每行item数量
<script setup>
+ import { ref, toRef, onMounted, onUnmounted } from "vue";
// ...
+ function updateLayout() {
+ if (containerRef.value) {
+ lineCount.value = calculateLineCount(containerRef.value);
+ getProcessItemList();
+ }
+}
// 获取渲染数据
+ function getProcessItemList() {
+ processedItemList.value = itemList.value;
+ itemsPerRow.value = calculateItemsPerRow();
+}
// 计算行数
+ function calculateLineCount(container) {
+ let lastTopOffset = null;
+ let count = 0;
+ for (let item of container.children) {
+ if (lastTopOffset === null) {
+ lastTopOffset = item.offsetTop;
+ count = 1;
+ } else if (item.offsetTop > lastTopOffset) {
+ lastTopOffset = item.offsetTop;
+ count++;
+ }
+ }
+ return count;
+ }
// 计算每行有多少个item
+ function calculateItemsPerRow() {
+ if (!containerRef.value) return 0;
+ const containerWidth = containerRef.value.offsetWidth;
+ const item = containerRef.value.querySelector(".item");
// 确保获取样式计算正确,包括外边距
+ const itemStyle = window.getComputedStyle(item);
+ const itemMargin =
+ parseInt(itemStyle.marginRight) + parseInt(itemStyle.marginLeft);
+ const itemWidth = item.offsetWidth + itemMargin;
+ return Math.floor(containerWidth / itemWidth);
+ }
+ onMounted(() => {
+ updateLayout();
+ window.addEventListener("resize", updateLayout);
+ });
+ onUnmounted(() => {
+ window.removeEventListener("resize", updateLayout);
+ });
</script>
- 首先创建一个窗口改变监听事件,每次窗口宽度改变时都会执行
updateLayout方法, 注意:在页面初始化时需要手动执行一次。 updateLayout做两件事,calculateLineCount计算行数 和calculateItemsPerRow计算每行的item个数- 当前的行数怎么计算呢?我想的办法是循环遍历每一个容器内的item,获取当前item距离顶部的距离:
offsetTop.lastTopOffset初始值为null,用于记录当前处理到的子元素上边距;count被设置为 0,用于累计换行次数。 - 获取每行的
item数量就比较容易了,只需要得到容器的宽度除以每个item的宽度+外边距 - 最后不要忘了
onUnmounted页面销毁时移除监听器。
3.实现偶数行逆序排列,奇数行顺序排列
// 获取渲染数据
function getProcessItemList() {
processedItemList.value = itemList.value;
itemsPerRow.value = calculateItemsPerRow();
+ if (lineCount.value > 2 && itemsPerRow.value > 1) {
+ processedItemList.value = reverseEvenRows(
+ [...itemList.value],
+ itemsPerRow.value
+ );
+ }
}
// 处理奇偶行
function reverseEvenRows(items, itemsPerRow) {
// 如果每行只能放置一个项目,或者所有项目可以放在一行中,就不进行倒序排列
if (itemsPerRow <= 1 || items.length <= itemsPerRow) {
return items;
}
// 否则,执行倒序排列逻辑
const result = [];
for (let i = 0; i < items.length; i += itemsPerRow) {
const rowItems = items.slice(i, i + itemsPerRow);
// 偶数行(从0开始计数)
let needReverse = Math.floor(i / itemsPerRow) % 2 === 1;
if (!needReverse) {
result.push(...rowItems);
} else {
result.push(...rowItems.reverse());
}
}
return result;
}
- 首先注意两个边界条件,当行数超过1行且每行item数量大于1个时,我们才需要对奇偶行处理
- 我这里的处理逻辑将数组的数据
分批次处理,比如说变成了三行,第一行是奇数行,那么数据顺序不变。 - 第二行是
偶数行,那么从8-15的元素就会进行倒序处理。 - 第三行是
奇数行,从16-19的数据也不变。 - 此时数组的数据就是:
[0, 1, 2, 3, 4, 5, 6, 7, 15, 14, 13, 12, 11, 10, 9, 8, 16, 17, 18, 19]
六、漏洞修复
当行数变成4行,每行6个的时候出现了bug。
即使最后一行进行了倒序处理,但是18并没有对齐上一行的17.
解决办法就是:补齐剩下的元素并且隐藏。
// 处理奇偶行
function reverseEvenRows(items, itemsPerRow) {
// 如果每行只能放置一个项目,或者所有项目可以放在一行中,就不进行倒序排列
if (itemsPerRow <= 1 || items.length <= itemsPerRow) {
return items;
}
// 否则,执行倒序排列逻辑
const result = [];
for (let i = 0; i < items.length; i += itemsPerRow) {
const rowItems = items.slice(i, i + itemsPerRow);
// 偶数行(从0开始计数)
let needReverse = Math.floor(i / itemsPerRow) % 2 === 1;
+ const isLastRow = i + itemsPerRow >= items.length
+ if (isLastRow) {
+ const lastRowIems = items.slice(i - itemsPerRow, i);
+ const currentRowLastItem = needReverse
+ ? rowItems[0]
+ : rowItems[rowItems.length - 1];
+ const lastRowLastItem = !needReverse
+ ? lastRowIems[0]
+ : lastRowIems[lastRowIems.length - 1];
+ // 最后一行不足一行,且最后两行最后一个是相邻位置
+ if (
+ currentRowLastItem - lastRowLastItem == 1 &&
+ rowItems.length < itemsPerRow
+ ) {
+ for (let i = 0; i <= itemsPerRow - rowItems.length + 2; i++) {
+ rowItems.push(undefined);
+ }
+ }
+ }
if (!needReverse) {
result.push(...rowItems);
} else {
result.push(...rowItems.reverse());
}
}
return result;
}
七、最终效果和完整代码
<template>
<div ref="containerRef" class="container">
<div v-for="(item, index) in processedItemList" :key="index" class="item" :style="{visibility: item!==undefined ? 'visible' : 'hidden'}">
{{ item }}
</div>
</div>
<p style="font-size: 60px;font-weight: bolder;">行数: {{ lineCount }},每行{{ itemsPerRow }}个</p>
</template>
<script setup>
import { ref, toRef, onMounted, onUnmounted } from "vue";
const containerRef = ref(null);
// 原始数据
const itemList = ref([...Array(20).keys()]);
// 渲染数据
let processedItemList = toRef(itemList.value);
// 行数
const lineCount = ref(0);
// 每行item数量
let itemsPerRow = ref(0);
function updateLayout() {
if (containerRef.value) {
lineCount.value = calculateLineCount(containerRef.value);
getProcessItemList();
}
}
// 获取渲染数据
function getProcessItemList() {
processedItemList.value = itemList.value;
itemsPerRow.value = calculateItemsPerRow();
if (lineCount.value > 2 && itemsPerRow.value > 1) {
processedItemList.value = reverseEvenRows(
[...itemList.value],
itemsPerRow.value
);
}
}
// 处理奇偶行
function reverseEvenRows(items, itemsPerRow) {
// 如果每行只能放置一个项目,或者所有项目可以放在一行中,就不进行倒序排列
if (itemsPerRow <= 1 || items.length <= itemsPerRow) {
return items;
}
// 否则,执行倒序排列逻辑
const result = [];
for (let i = 0; i < items.length; i += itemsPerRow) {
const rowItems = items.slice(i, i + itemsPerRow);
// 偶数行(从0开始计数)
let needReverse = Math.floor(i / itemsPerRow) % 2 === 1;
const isLastRow = i + itemsPerRow >= items.length
if (isLastRow) {
const lastRowIems = items.slice(i - itemsPerRow, i);
const currentRowLastItem = needReverse
? rowItems[0]
: rowItems[rowItems.length - 1];
const lastRowLastItem = !needReverse
? lastRowIems[0]
: lastRowIems[lastRowIems.length - 1];
// 最后一行不足一行,且最后两行最后一个是相邻位置
if (
currentRowLastItem - lastRowLastItem == 1 &&
rowItems.length < itemsPerRow
) {
for (let i = 0; i <= itemsPerRow - rowItems.length + 2; i++) {
rowItems.push(undefined);
}
}
}
if (!needReverse) {
result.push(...rowItems);
} else {
result.push(...rowItems.reverse());
}
}
console.log(result)
return result;
}
// 计算行数
function calculateLineCount(container) {
let lastTopOffset = null;
let count = 0;
for (let item of container.children) {
if (lastTopOffset === null) {
lastTopOffset = item.offsetTop;
count = 1;
} else if (item.offsetTop > lastTopOffset) {
lastTopOffset = item.offsetTop;
count++;
}
}
return count;
}
// 计算每行有多少个item
function calculateItemsPerRow() {
if (!containerRef.value) return 0;
const containerWidth = containerRef.value.offsetWidth;
const item = containerRef.value.querySelector(".item");
// 确保获取样式计算正确,包括外边距
const itemStyle = window.getComputedStyle(item);
const itemMargin =
parseInt(itemStyle.marginRight) + parseInt(itemStyle.marginLeft);
const itemWidth = item.offsetWidth + itemMargin;
return Math.floor(containerWidth / itemWidth);
}
onMounted(() => {
updateLayout();
window.addEventListener("resize", updateLayout);
});
onUnmounted(() => {
window.removeEventListener("resize", updateLayout);
});
</script>
<style>
.container {
display: flex;
flex-wrap: wrap;
}
.item {
width: 100px;
height: 100px;
margin: 5px;
background-color: #3b4eff;
display: flex;
color: white;
justify-content: center;
align-items: center;
}
</style>