横向图片选择器之自动滚动定位功能-Javascript、Vue

写在开头

Hello,各位UU早上好呀! 😀

今是2025年04月12日,小编已经躺平三四个月没来写文章了,主要原因是去年年底进行了跳槽,在新公司业务比较忙,实在是没有时间上来水✏,绝对不是懒哈!😋

然后呢,跳槽还是比较顺利的,在新公司一切都挺好,工作日每天忙忙碌碌,从早晨吃饭-上班-下班-吃饭-午休-上班-下班-吃饭-回宿舍-洗澡-睡觉,周而复始;而周末一有时间就外出约好友爬山、逛公园、看美景、吃美食,一切都挺好,平平淡淡、顺顺利利。

最近读到两句鸡汤,也分享给大家👻:

  • 当知足凌驾于欲望之上,幸福将会贯彻一生!

  • 别人开导只是问诊,自己醒悟方为良药!

那么,回到正题,本次要分享的是关于自动滚动定位的功能,效果如下,请诸君按需食用哈。

需求背景

最近在做公司业务时,遇到了一个关于横向图片选择器的交互小需求。👀

具体来说,当用户在横向的图片选择器中点击某张图片时,需要自动调整滚动位置,确保点击的图片以及其后续几张图片能够完整显示在可视区域内。这种交互能很好的提升用户体验,在移动端和桌面端的应用中比较常见,比如图片浏览器、商品展示列表等场景。

虽然这个需求虽然并不复杂,但实现起来全是细节,如:

  • 滚动范围的计算
  • 容器宽度的动态适配
  • 滚动动画的平滑效果。
  • 懒加载
  • 键盘上下键选择
  • ...

实现过程

小编最开始是在比较复杂的业务项目中实现的该功能,但为了更好的理解,咱们先用基础的三剑客来实现,文章最后也会提供 Vue3 版本,可以自行选择。😋

由于精简过代码,咱们来直接瞧瞧:

html 复制代码
<!DOCTYPE html>
<html>
<head>
  <style>
    .demo-container {
        margin: 30px 0;
        padding: 20px;
        background-color: #f9f9f9;
        border-radius: 8px;
        box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
    }
    .image-selector {
        width: 100%;
        height: 150px;
        overflow-x: auto;
        overflow-y: hidden;
        display: flex;
        align-items: center;
        box-sizing: border-box;
        margin: 20px 0;
        background-color: #f0f0f0;
        border-radius: 8px;
        padding: 10px;
    }
    .image-item {
        min-width: 80px;
        width: 80px;
        height: 120px;
        margin-right: 10px;
        border-radius: 4px;
        overflow: hidden;
        border: 3px solid #ddd;
        box-sizing: border-box;
        cursor: pointer;
        background-color: #fff;
        transition: all 0.3s;
        flex-shrink: 0;
    }
    .image-item.active {
        border-color: #3498db;
    }
    .image-item img {
        width: 100%;
        height: 100%;
        object-fit: cover;
    }
    .btn {
        display: inline-block;
        padding: 8px 16px;
        background-color: #3498db;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        text-decoration: none;
        margin: 5px;
    }
    .btn:hover {
        background-color: #2980b9;
    }
  </style>
</head>
<body>
  <div class="demo-container">
    <h3>演示案例</h3>
    <div id="imageSelector" class="image-selector">
        <!-- 图片将通过JS动态生成 -->
    </div>
    <button id="addBtn" class="btn">添加更多图片</button>
    <button id="resetBtn" class="btn">重置</button>
    <button id="prevBtn" class="btn">上一个</button>
    <button id="nextBtn" class="btn">下一个</button>
  </div>
</body>
</html>

逻辑部分:

js 复制代码
<script>
    // 先把所有相关DOM先获取一下
    const container = document.getElementById("imageSelector");
    const addBtn = document.getElementById("addBtn");
    const resetBtn = document.getElementById("resetBtn");
    const prevBtn = document.getElementById("prevBtn");
    const nextBtn = document.getElementById("nextBtn");
    /** @name 图片资源 **/
    const imageUrls = [
        "https://picsum.photos/200/300?random=1",
        "https://picsum.photos/200/300?random=2",
        "https://picsum.photos/200/300?random=3",
        "https://picsum.photos/200/300?random=4",
        "https://picsum.photos/200/300?random=5",
        "https://picsum.photos/200/300?random=6",
        "https://picsum.photos/200/300?random=7",
        "https://picsum.photos/200/300?random=8",
        "https://picsum.photos/200/300?random=9",
        "https://picsum.photos/200/300?random=10",
        "https://picsum.photos/200/300?random=11",
        "https://picsum.photos/200/300?random=12",
        "https://picsum.photos/200/300?random=13",
        "https://picsum.photos/200/300?random=14",
        "https://picsum.photos/200/300?random=15",
    ];
    /** @name 当前选择的图片索引 **/
    let currentIndex = 0;
     // 宽度 + 边距
    const itemWidth = 80 + 10;

    /** @name 初始化图片 **/
    function initImages() {
      container.innerHTML = "";
      imageUrls.forEach((url, index) => {
        const item = document.createElement("div");
        item.className = "image-item";
        if (index === currentIndex) item.classList.add("active");
        item.innerHTML = `<img src="${url}" alt="截图 ${index + 1}">`;
        item.addEventListener("click", () => selectImage(index));
        container.appendChild(item);
      });
    }

    // 初始化
    initImages();

    /** @name 选择图片 **/
    function selectImage(index) {
      // 移除之前选中的active类
      document.querySelectorAll(".image-item").forEach((el) => {
        el.classList.remove("active");
      });
      currentIndex = index;
      // 添加active类
      container.children[index].classList.add("active");
      // 自动滚动逻辑
      scrollToItem(index);
    }
    /** @name 滚动定位 **/
    function scrollToItem(index) {
      const containerWidth = container.clientWidth;
      const scrollLeft = container.scrollLeft;
      const itemLeft = index * itemWidth;
      // 计算理想滚动位置,确保点击项和后续3个项都能显示
      const itemsToShow = 4;
      const idealScrollLeft = itemLeft - (containerWidth - itemWidth * itemsToShow) / 2;
      // 限制滚动范围
      const maxScrollLeft = container.scrollWidth - containerWidth;
      const targetScrollLeft = Math.max(0, Math.min(idealScrollLeft, maxScrollLeft));
      // 只有当当前位置与目标位置不同时才滚动
      if (Math.abs(scrollLeft - targetScrollLeft) > 1) {
        container.scrollTo({ left: targetScrollLeft, behavior: "smooth" });
      }
    }
    /** @name 添加更多图片 **/
    function addImage() {
      const newIndex = imageUrls.length;
      const newImageUrl = `https://picsum.photos/200/300?random=${newIndex + 1}`;
      imageUrls.push(newImageUrl);
      // 创建新的图片项
      const item = document.createElement("div");
      item.className = "image-item";
      item.innerHTML = `<img src="${newImageUrl}" alt="截图 ${newIndex + 1}">`;
      item.addEventListener("click", () => selectImage(newIndex));
      // 将新图片项添加到容器中
      container.appendChild(item);
      // 选中新添加的图片
      selectImage(newIndex);
    }

    /** @name 重置 **/
    function reset() {
      currentIndex = 0;
      initImages();
      container.scrollTo({ left: 0, behavior: "smooth" });
    }
    /** @name 上一个 **/
    function prevImage() {
      if (currentIndex > 0) {
        selectImage(currentIndex - 1);
      }
    }
    /** @name 下一个 **/
    function nextImage() {
      if (currentIndex < imageUrls.length - 1) {
        selectImage(currentIndex + 1);
      }
    }
    /** @name 键盘事件处理 **/
    function handleKeyDown(e) {
      switch (e.key) {
        case "ArrowUp":
            prevImage();
            break;
        case "ArrowDown":
            nextImage();
            break;
      }
    }
    /** @name 初始化事件监听器 **/
    function initEventListeners() {
      addBtn.addEventListener("click", addImage);
      resetBtn.addEventListener("click", reset);
      prevBtn.addEventListener("click", prevImage);
      nextBtn.addEventListener("click", nextImage);
      document.addEventListener("keydown", handleKeyDown);
    }
    
    initEventListeners();
</script>

图片资源可以使用这个在线网站,非常好用:传送门

比较核心的逻辑是通过计算点击项的位置和容器宽度,确定最佳的滚动位置。这里小编单独写了一个 scrollToItem() 方法来完成这个事情,可以自己瞧瞧,都标记了详细注释。😊

懒加载

以上基础功能就都已经实现了,还有一个懒加载图片的能力,可以参考小编的另一篇文章:传送门

懒加载功能使用 IntersectionObserver 对象来实现非常简单,这里就不做过多介绍啦。👻

Vue3版本hook

🙊呃...由于小编项目使用的是 TS 语言,如果你想要 JS 版本,最快的方式就是直接复制代码丢给AI,让它帮你出一个 JS 版本即可。😋

Vue3 版本还增加了另外的一些小功能:

  • 边界检测
  • 指示箭头
  • 惯性滚动效果
  • 触摸设备支持
  • 懒加载功能
ts 复制代码
import { ref, onMounted, onUnmounted } from "vue";

interface UseAutoScrollOptions {
  itemWidth: number; // 单个图片项的宽度(包括边距)
  itemsToShow: number; // 可视区域内显示的图片数量
}

export function useAutoScroll(options: UseAutoScrollOptions) {
  const container = ref<HTMLDivElement | null>(null);
  const images = ref<string[]>([]);
  const currentIndex = ref(0);
  const showPrevArrow = ref(false);
  const showNextArrow = ref(false);

  let touchStartX = 0;
  let touchDeltaX = 0;

  const { itemWidth, itemsToShow } = options;

  /** @name 初始化图片 */
  const initImages = (initialImages: string[]) => {
    images.value = initialImages;
    updateArrows();
  };

  /** @name 选择图片 */
  const selectImage = (index: number) => {
    if (index < 0 || index >= images.value.length) return;

    currentIndex.value = index;
    scrollToItem(index);
    updateArrows();
  };

  /** @name 滚动到指定图片 */
  const scrollToItem = (index: number) => {
    if (!container.value) return;

    const containerWidth = container.value.clientWidth;
    const itemLeft = index * itemWidth;
    const idealScrollLeft =
      itemLeft - (containerWidth - itemWidth * itemsToShow) / 2;

    const maxScrollLeft = container.value.scrollWidth - containerWidth;
    const targetScrollLeft = Math.max(0, Math.min(idealScrollLeft, maxScrollLeft));

    container.value.scrollTo({
      left: targetScrollLeft,
      behavior: "smooth",
    });
  };

  /** @name 添加图片 */
  const addImage = (url: string) => {
    images.value.push(url);
    updateArrows();
  };

  /** @name 更新箭头显示状态 */
  const updateArrows = () => {
    if (!container.value) return;

    const containerWidth = container.value.clientWidth;
    const maxScrollLeft = container.value.scrollWidth - containerWidth;

    showPrevArrow.value = container.value.scrollLeft > 0;
    showNextArrow.value = container.value.scrollLeft < maxScrollLeft;
  };

  /** @name 触摸开始事件 */
  const handleTouchStart = (e: TouchEvent) => {
    touchStartX = e.touches[0].clientX;
  };

  /** @name 触摸移动事件 */
  const handleTouchMove = (e: TouchEvent) => {
    touchDeltaX = e.touches[0].clientX - touchStartX;
  };

  /** @name 触摸结束事件 */
  const handleTouchEnd = () => {
    if (Math.abs(touchDeltaX) > 50) {
      if (touchDeltaX > 0) {
        selectImage(currentIndex.value - 1);
      } else {
        selectImage(currentIndex.value + 1);
      }
    }
    touchDeltaX = 0;
  };

  /** @name 懒加载图片 */
  const lazyLoadImages = () => {
    if (!container.value) return;

    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const img = entry.target as HTMLImageElement;
            const src = img.getAttribute("data-src");
            if (src) {
              img.src = src;
              observer.unobserve(img);
            }
          }
        });
      },
      { root: container.value, threshold: 0.1 }
    );

    container.value.querySelectorAll("img[data-src]").forEach((img) => {
      observer.observe(img);
    });
  };

  /** @name 初始化事件监听器 */
  const initEventListeners = () => {
    if (!container.value) return;

    container.value.addEventListener("scroll", updateArrows);
    container.value.addEventListener("touchstart", handleTouchStart);
    container.value.addEventListener("touchmove", handleTouchMove);
    container.value.addEventListener("touchend", handleTouchEnd);
  };

  /** @name 移除事件监听器 */
  const removeEventListeners = () => {
    if (!container.value) return;

    container.value.removeEventListener("scroll", updateArrows);
    container.value.removeEventListener("touchstart", handleTouchStart);
    container.value.removeEventListener("touchmove", handleTouchMove);
    container.value.removeEventListener("touchend", handleTouchEnd);
  };

  onMounted(() => {
    initEventListeners();
    lazyLoadImages();
  });

  onUnmounted(() => {
    removeEventListeners();
  });

  return {
    container,
    images,
    currentIndex,
    showPrevArrow,
    showNextArrow,
    initImages,
    selectImage,
    addImage,
  };
}

Vue 组件中使用该 Hook

js 复制代码
<template>
  <div class="image-selector-container">
    <!-- 箭头可以自行换个好看一点的图片替代\(^o^)/~ -->
    <button v-if="showPrevArrow" @click="selectImage(currentIndex - 1)">←</button>
    <div ref="container" class="image-selector">
      <div
        v-for="(image, index) in images"
        :key="index"
        class="image-item"
        :class="{ active: index === currentIndex }"
        @click="selectImage(index)"
      >
        <img :data-src="image" alt="图片" />
      </div>
    </div>
    <button v-if="showNextArrow" @click="selectImage(currentIndex + 1)">→</button>
  </div>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import { useAutoScroll } from "./useAutoScroll";

export default defineComponent({
  setup() {
    const { container, images, currentIndex, showPrevArrow, showNextArrow, initImages, selectImage, addImage } =
      useAutoScroll({ itemWidth: 90, itemsToShow: 4 });

    // 初始化图片
    initImages([
      "https://picsum.photos/200/300?random=1",
      "https://picsum.photos/200/300?random=2",
      "https://picsum.photos/200/300?random=3",
      "https://picsum.photos/200/300?random=4",
    ]);

    return {
      container,
      images,
      currentIndex,
      showPrevArrow,
      showNextArrow,
      selectImage,
      addImage,
    };
  },
});
</script>

<style>
.image-selector-container {
  display: flex;
  align-items: center;
}
.image-selector {
  display: flex;
  overflow-x: auto;
  width: 100%;
  height: 150px;
}
.image-item {
  min-width: 80px;
  height: 120px;
  margin-right: 10px;
  border: 2px solid #ddd;
  transition: border-color 0.3s;
}
.image-item.active {
  border-color: #3498db;
}
</style>

至此,本篇文章就写完啦,撒花撒花。

相关推荐
JiangJiang16 分钟前
🚀 Vue人看React useRef:它不只是替代 ref
javascript·react.js·面试
1024小神20 分钟前
在GitHub action中使用添加项目中配置文件的值为环境变量
前端·javascript
龙骑utr25 分钟前
qiankun微应用动态设置静态资源访问路径
javascript
Jasmin Tin Wei25 分钟前
css易混淆的知识点
开发语言·javascript·ecmascript
齐尹秦28 分钟前
CSS 列表样式学习笔记
前端
wsz777733 分钟前
js封装系列(一)
javascript
Mnxj33 分钟前
渐变边框设计
前端
用户76787977373235 分钟前
由Umi升级到Next方案
前端·next.js
快乐的小前端36 分钟前
TypeScript基础一
前端
北凉温华37 分钟前
UniApp项目中的多服务环境配置与跨域代理实现
前端