【创意CSS实战】:蜿蜒灵动,一文揭秘Vue3项目如何打造惊艳的蛇形布局设计!

349 阅读6分钟

一、前言

hello~大家好!上周某一天群里突然消息猛增,点开一看,原来是一位老哥在开发中需要实现一种类似蛇形(也有叫弓形)布局的效果,详情请看下图:

a1031ebc48a8c4e732c8e387740f2f7.jpg

二、应用场景

仔细想想,这类布局其实在业务需求中是挺常见的,我总结了以下几个业务场景

  1. 抽奖 image.png

image.png

  1. 签到

  2. 步骤条

在群里给那位老哥提供思路和资料的同时,我自己也发现了不少问题。尽管网上其他作者都成功展示了该布局的独特视觉效果,但各自的实现方案存在一定的局限性。比如,有的方案仅适用于固定数量的行数固定每行元素个数,无法灵活应对多变的动态场景。

考虑到实际应用中页面内容和布局需求可能频繁变动的情况,我决定亲自探索一种更为通用且适应性强的方法,实现真正意义上的动态蛇形布局效果,以克服上述提到的各种限制。

接下来,我将分享我的实践过程与具体实现代码,旨在提供一个无论在任何复杂场景下都能保持流畅、灵活布局表现的解决方案,希望这能对您的工作带来实质性的帮助和启示~

三、思路

  1. 根据页面宽度变化求出每行元素个数和行数
  2. 奇数行的元素顺序排列
  3. 偶数行的元素逆序排列

四、温馨提示:

如果图中的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官网学习

这样我们就实现了宽度不足时,元素自动换行的效果,请看下图:

1.gif

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>
  1. 首先创建一个窗口改变监听事件,每次窗口宽度改变时都会执行updateLayout方法, 注意:在页面初始化时需要手动执行一次。
  2. updateLayout做两件事,calculateLineCount计算行数 和 calculateItemsPerRow 计算每行的item个数
  3. 当前的行数怎么计算呢?我想的办法是循环遍历每一个容器内的item,获取当前item距离顶部的距离:offsetTop.lastTopOffset初始值为null,用于记录当前处理到的子元素上边距;count 被设置为 0,用于累计换行次数。
  4. 获取每行的item数量就比较容易了,只需要得到容器的宽度除以每个item的宽度+外边距
  5. 最后不要忘了onUnmounted页面销毁时移除监听器。

动画.gif

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. 首先注意两个边界条件,当行数超过1行且每行item数量大于1个时,我们才需要对奇偶行处理
  2. 我这里的处理逻辑将数组的数据分批次处理,比如说变成了三行,第一行是奇数行,那么数据顺序不变
  3. 第二行是偶数行,那么从8-15的元素就会进行倒序处理。
  4. 第三行是奇数行,从16-19的数据也不变。
  5. 此时数组的数据就是:[0, 1, 2, 3, 4, 5, 6, 7, 15, 14, 13, 12, 11, 10, 9, 8, 16, 17, 18, 19]

image.png

六、漏洞修复

当行数变成4行,每行6个的时候出现了bug。 即使最后一行进行了倒序处理,但是18并没有对齐上一行的17. 解决办法就是:补齐剩下的元素并且隐藏

image.png

 // 处理奇偶行
 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;
  }

七、最终效果和完整代码

4.gif

<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>