横向图片选择器之自动滚动定位功能-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>

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

相关推荐
九月TTS30 分钟前
TTS-Web-Vue系列:移动端侧边栏与响应式布局深度优化
前端·javascript·vue.js
曾经的你d30 分钟前
【electron+vue】常见功能之——调用打开/关闭系统软键盘,解决打包后键盘无法关闭问题
vue.js·electron·计算机外设
Johnstons32 分钟前
AnaTraf:深度解析网络性能分析(NPM)
前端·网络·安全·web安全·npm·网络流量监控·网络流量分析
whatever who cares1 小时前
CSS3 伪元素(Pseudo-elements)大全
前端·css·css3
若愚67921 小时前
前端取经路——性能优化:唐僧的九道心经
前端·性能优化
积极向上的龙1 小时前
首屏优化,webpack插件用于给html中js自动添加异步加载属性
javascript·webpack·html
Bl_a_ck2 小时前
开发环境(Development Environment)
开发语言·前端·javascript·typescript·ecmascript
田本初2 小时前
使用vite重构vue-cli的vue3项目
前端·vue.js·重构
ai产品老杨2 小时前
AI赋能安全生产,推进数智化转型的智慧油站开源了。
前端·javascript·vue.js·人工智能·ecmascript
帮帮志2 小时前
vue实现与后台springboot传递数据【传值/取值 Axios 】
前端·vue.js·spring boot