vue 实现轮播 - 一张半效果

1 实现效果 2 实现方案

文档核心内容一览:

  1. 需求描述 --- 卡片式轮播的功能点清单(左对齐 + 半张露出、拖动吸附、3 秒自动播、指示条)
  2. 整体结构 --- 视口容器裁切 + 轨道整体 translateX 平移的核心思路
  3. 布局公式 --- 卡宽 W = (屏宽 - 左留白 - 间距) / 1.5,保证任意屏宽下都是"完整一张 + 半张下一张";轨道偏移 L - i × (W + G);并记录了 gap 不一致导致末卡被裁的踩坑点
  4. 手势交互 --- 方向锁定防滚动冲突、拖动跟手/松手吸附两态切换、快速轻扫判定、边界阻尼
  5. 自动轮播 --- 3 秒定时器、拖动暂停/松手恢复、组件销毁清理
  6. 状态设计 --- 视图由 activeIndex + dragDelta 两个量驱动的极简模型
  7. 可扩展点 --- 无限循环、圆点指示、3D 层叠、迁移到 SFC 工程的改造方法

H5 卡片式轮播实现方案文档

技术栈:Vue 2.7(CDN 引入,无需构建) · 单文件 index.html · 纯 CSS + 原生 JS 交互


一、需求描述

移动端 H5 活动页常见的卡片式轮播(非单图轮播):

  1. 卡片承载图文内容:大图 + 状态角标(如"征集中")+ 标题 + 描述 + 票数;

  2. 布局为左对齐 :当前卡片完整展示、紧贴左侧留白,右侧恰好露出下一张卡片的一半,暗示可滑动;

  3. 所有卡片大小一致,无缩放差异;

  4. 支持手指/鼠标拖动跟手滑动,松手自动吸附到最近卡片;

  5. 底部有分段式指示条,当前页为红色长条;

  6. 3 秒自动轮播,循环播放,用户拖动时暂停、松手恢复。


二、整体结构

arduino 复制代码
#app

├── .carousel-wrap // 视口容器,overflow: hidden,负责裁切与事件监听

│ └── .carousel-track // 横向轨道(flex 布局),整体 translateX 平移

│ └── .card × N // 卡片(图片 + 角标 + 标题 + 描述 + 票数)

└── .indicator // 底部分段指示条

核心思路:不逐个移动卡片,而是平移整条轨道 translateX,把"当前是第几张"映射为"轨道整体偏移多少"。


三、布局与尺寸计算(关键公式)

3.1 卡片宽度

设:屏宽 vw、左留白 L(16px)、卡片间距 G(12px)、卡片宽 W。

"一张完整卡片 + 半张下一卡" 恰好占满屏宽:

ini 复制代码
L + W + G + W/2 = vw

解得:

ini 复制代码
W = (vw - L - G) / 1.5

该公式保证任意屏宽下都严格呈现"完整一张 + 半张下一张"的视觉效果(响应式)。

3.2 轨道偏移

轨道采用 position: absolute; left: 0,通过 transform 平移。第 i 张为当前卡片时的偏移量:

ini 复制代码
translateX = L - i × (W + G)

即当前卡片左边缘永远落在 x = L 处(左对齐)。

3.3 响应式

监听 window.resize 重新执行 layout(),重算 W 与图片高度;beforeDestroy 时移除监听、清理定时器。

⚠️ 踩坑记录:JS 定位公式里假设卡片间有 G=12px 间距,CSS 轨道必须设置 gap: 12px 与之严格一致,否则每张卡片会逐张累积偏移(这正是"最后一张显示不全"的 bug 根因)。


四、拖动手势交互

4.1 事件与方向锁定

  • 移动端 touchstart / touchmove / touchend,PC 端 mousedown + window 级 mousemove / mouseup(Vue2 中用原生事件兼容两种环境)。

  • touchmove 中做方向锁定 (lockDirection):首次位移超过 8px 时判断主方向,横向(x)则接管事件并 preventDefault(),纵向(y)则立即结束拖动、把滚动权还给页面------避免与页面上下滚动冲突。

4.2 跟手与吸附

拖动过程分两态:

| 阶段 | 动画 | 说明 |

|---|---|---|

| 拖动中 | transition: none(no-anim) | dragDelta 直接叠加到轨道偏移上,跟手 |

| 松手后 | transition: cubic-bezier(anim) | 移除 dragDelta,让 activeIndex 变化驱动吸附动画 |

松手后通过 moved = Math.round(-dragDelta / step) 计算翻过几张(step = W + G)。

快速轻扫判定(提升手感):若位移不足半张但满足任一条件,也翻一页:

  • 位移超过卡片宽的 18%;

  • 拖动速度快(|delta| / 拖动时长 > 0.5px/ms)且位移超过 30px。

4.3 边界阻尼

首/尾边界处拖动位移乘以 0.35 的阻尼系数,产生"拉不动 + 松手弹回"的原生手感:

js 复制代码
if ((i === 0 && dx > 0) || (i === last && dx < 0)) return dx * 0.35;

4.4 点击卡片切换

点击非当前卡片直接 go(i);通过 Math.abs(dragDelta) > 5 过滤拖动后的误触点击。


五、自动轮播

js 复制代码
setInterval(() => {

this.activeIndex >= list.length - 1 ? this.go(0) : this.next();

}, 3000);
  • 暂停/恢复 :startDrag 时 pauseAutoplay,endDrag 后 startAutoplay(同时会重置计时,避免松手瞬间立刻跳页);

  • 循环:到末尾后回到第 0 张;

  • 清理 :beforeDestroy 中 clearInterval。


六、指示条

指示条与卡片数量一致,用 v-for 渲染:

html 复制代码
<span class="bar" :class="{ on: i === activeIndex }"></span>

当前项为红色加宽长条(44px),其余为灰色短条(22px),宽度/颜色变化走 CSS transition,无需 JS 动画。


七、状态设计小结

scss 复制代码
list 轮播数据(img / title / desc / votes / badge)

activeIndex 当前卡片索引(唯一驱动视图的状态)

cardWidth / gap / leftPad / imgHeight 布局尺寸(layout() 计算)

dragging / dragDelta / startX / startY / startT / lockDirection 拖动临时状态

animating 是否启用过渡动画(拖动时关闭)

autoplayTimer 自动播放句柄

视图完全由 activeIndex + dragDelta 两个运行时量驱动:

ini 复制代码
translateX = L - activeIndex × (W + G) + dragDelta

八、可扩展点

| 需求 | 改法 |

|---|---|

| 无限循环轮播 | 数据渲染两份(list.concat(list)),snap 后对索引取模回跳(关闭动画回跳) |

| 底部圆点指示 | .indicator .bar 改为固定宽度圆形样式 |

| 3D 层叠效果 | 给非当前卡片加 transform: scale() + 透明度过渡(本文档初版方案) |

| 自动播放间隔调整 | 修改 setInterval 第二参数(毫秒) |

| 接入真实数据 | 替换 list 数组内容即可,卡片结构在 template 中调整 |

| 打包进工程 | 无构建依赖,可将 <script> 段抽为 SFC 组件,data/methods 原样迁移,事件换 @touchstart 等模板绑定 |


九、文件清单

  • index.html ------ 单文件实现(样式 + 结构 + 逻辑),本地静态服务器即可运行
js 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>卡片轮播 - Vue2 H5</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.min.js"></script>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
  html, body {
    width: 100%; height: 100%;
    font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
    background: #fdfbf7;
    overflow: hidden;
    -webkit-user-select: none; user-select: none;
  }
  #app {
    height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
  }

  /* ===== 轮播区域 ===== */
  .carousel-wrap {
    position: relative;
    width: 100%;
    height: 560px;
    overflow: hidden;
  }
  .carousel-track {
    position: absolute;
    top: 0; left: 0;
    height: 100%;
    display: flex;
    align-items: center;
    will-change: transform;
  }
  .carousel-track.no-anim { transition: none; }
  .carousel-track.anim { transition: transform .45s cubic-bezier(.22,.61,.36,1); }

  .card {
    flex-shrink: 0;
    background: #fff;
    border-radius: 14px;
    overflow: hidden;
    box-shadow: 0 6px 20px rgba(0,0,0,.08);
    transform: scale(.9);
    opacity: .75;
    transition: transform .45s cubic-bezier(.22,.61,.36,1), opacity .45s;
    cursor: pointer;
  }
  .card.active {
    transform: scale(1);
    opacity: 1;
  }

  .card-img {
    position: relative;
    width: 100%;
    background: #eee;
  }
  .card-img img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  .badge {
    position: absolute;
    top: 10px; right: 10px;
    padding: 4px 10px;
    font-size: 11px;
    color: #fff;
    background: rgba(0,0,0,.45);
    border-radius: 20px;
    backdrop-filter: blur(2px);
  }

  .card-body { padding: 14px 16px 16px; }
  .card-title {
    font-size: 17px;
    font-weight: 600;
    color: #2b2b2b;
    margin-bottom: 6px;
  }
  .card-desc {
    font-size: 13px;
    color: #9a9a9a;
    margin-bottom: 12px;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .card-votes {
    display: flex;
    align-items: center;
    font-size: 14px;
    font-weight: 600;
    color: #c0392b;
  }
  .heart {
    width: 14px; height: 14px;
    margin-right: 5px;
    fill: #c0392b;
  }

  /* ===== 底部指示条 ===== */
  .indicator {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 22px 0 30px;
  }
  .indicator .bar {
    height: 3px;
    width: 22px;
    border-radius: 2px;
    background: #e3ddd3;
    transition: all .35s;
  }
  .indicator .bar.on {
    width: 44px;
    background: #c0392b;
  }
</style>
</head>
<body>
<div id="app">
  <!-- 轮播 -->
  <div class="carousel-wrap"
       @touchstart="onTouchStart"
       @touchmove="onTouchMove"
       @touchend="onTouchEnd"
       @mousedown="onMouseDown">
    <div class="carousel-track" :class="animating ? 'anim' : 'no-anim'" :style="trackStyle">
      <div class="card"
           v-for="(item, i) in list"
           :key="i"
           :class="{ active: i === activeIndex }"
           :style="cardStyle"
           @click="onClickCard(i)">
        <div class="card-img" :style="{ height: imgHeight + 'px' }">
          <img :src="item.img" :alt="item.title" draggable="false">
          <span class="badge">{{ item.badge }}</span>
        </div>
        <div class="card-body">
          <div class="card-title">{{ item.title }}</div>
          <div class="card-desc">{{ item.desc }}</div>
          <div class="card-votes">
            <svg class="heart" viewBox="0 0 24 24">
              <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
            </svg>
            {{ item.votes }} 票
          </div>
        </div>
      </div>
    </div>
  </div>

  <!-- 指示条 -->
  <div class="indicator">
    <span class="bar" v-for="(item, i) in list" :key="'b' + i" :class="{ on: i === activeIndex }"></span>
  </div>
</div>

<script>
new Vue({
  el: '#app',
  data: function () {
    return {
      list: [
        { title: '给远方的你', desc: '隔着山海,也是挂念。', votes: 15, badge: '征集中', img: 'https://picsum.photos/seed/carousel1/700/720' },
        { title: '谢谢一路同行', desc: '漫长的路,感谢有你。', votes: 22, badge: '征集中', img: 'https://picsum.photos/seed/carousel2/700/720' },
        { title: '晚风与星光', desc: '把心事说给晚风听。', votes: 38, badge: '征集中', img: 'https://picsum.photos/seed/carousel3/700/720' },
        { title: '旧时光的信', desc: '写给十年后的自己。', votes: 9, badge: '征集中', img: 'https://picsum.photos/seed/carousel4/700/720' }
      ],
      activeIndex: 0,
      // 布局尺寸(px,运行时按屏宽计算)
      cardWidth: 0,
      gap: 12,
      imgHeight: 0,
      // 拖动状态
      dragging: false,
      dragDelta: 0,
      startX: 0,
      startY: 0,
      startT: 0,
      lockDirection: '',   // 'x' | 'y',判断滑动方向
      animating: true
    };
  },
  computed: {
    trackStyle: function () {
      var center = window.innerWidth / 2;
      var offset = center - this.cardWidth / 2 - this.activeIndex * (this.cardWidth + this.gap);
      return {
        width: this.list.length * (this.cardWidth + this.gap) + 'px',
        transform: 'translateX(' + (offset + this.dragDelta) + 'px)'
      };
    },
    cardStyle: function () {
      return { width: this.cardWidth + 'px' };
    }
  },
  created: function () {
    this.layout();
    window.addEventListener('resize', this.layout);
  },
  methods: {
    layout: function () {
      var vw = window.innerWidth;
      // 卡片宽 = 屏宽 - 两侧露出 (每侧 34px)
      this.cardWidth = vw - 68;
      this.imgHeight = Math.round(this.cardWidth * 0.98);
    },
    go: function (i) {
      if (i < 0) i = 0;
      if (i > this.list.length - 1) i = this.list.length - 1;
      this.activeIndex = i;
    },
    next: function () { this.go(this.activeIndex + 1); },
    prev: function () { this.go(this.activeIndex - 1); },
    onClickCard: function (i) {
      // 拖动后误触忽略;点非当前卡片则切换过去
      if (Math.abs(this.dragDelta) > 5) return;
      if (i !== this.activeIndex) this.go(i);
    },

    /* ===== Touch ===== */
    onTouchStart: function (e) {
      this.startDrag(e.touches[0].clientX, e.touches[0].clientY);
    },
    onTouchMove: function (e) {
      if (!this.dragging) return;
      var dx = e.touches[0].clientX - this.startX;
      var dy = e.touches[0].clientY - this.startY;
      if (!this.lockDirection) {
        if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
          this.lockDirection = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
        }
      }
      if (this.lockDirection === 'y') { this.endDrag(); return; }
      if (this.lockDirection === 'x') e.preventDefault();
      this.dragDelta = this.calcDelta(dx);
    },
    onTouchEnd: function () {
      this.endDrag();
    },

    /* ===== Mouse ===== */
    onMouseDown: function (e) {
      this.startDrag(e.clientX, e.clientY);
      window.addEventListener('mousemove', this.onMouseMove);
      window.addEventListener('mouseup', this.onMouseUp);
    },
    onMouseMove: function (e) {
      if (!this.dragging) return;
      this.dragDelta = this.calcDelta(e.clientX - this.startX);
    },
    onMouseUp: function () {
      window.removeEventListener('mousemove', this.onMouseMove);
      window.removeEventListener('mouseup', this.onMouseUp);
      this.endDrag();
    },

    startDrag: function (x, y) {
      this.dragging = true;
      this.animating = false;
      this.startX = x;
      this.startY = y;
      this.startT = Date.now();
      this.lockDirection = '';
    },
    calcDelta: function (dx) {
      // 边界阻尼
      var atFirst = this.activeIndex === 0 && dx > 0;
      var atLast = this.activeIndex === this.list.length - 1 && dx < 0;
      if (atFirst || atLast) return dx * 0.35;
      return dx;
    },
    endDrag: function () {
      if (!this.dragging) return;
      this.dragging = false;
      this.animating = true;
      var delta = this.dragDelta;
      this.dragDelta = 0;
      var step = this.cardWidth + this.gap;
      var moved = Math.round(-delta / step);
      // 快速轻扫也可翻页
      var speed = Math.abs(delta) / Math.max(Date.now() - this.startT, 1);
      if (moved === 0 && Math.abs(delta) > this.cardWidth * 0.18) {
        moved = delta < 0 ? 1 : -1;
      } else if (moved === 0 && speed > 0.5 && Math.abs(delta) > 30) {
        moved = delta < 0 ? 1 : -1;
      }
      this.go(this.activeIndex + moved);
    }
  }
});
</script>
</body>
</html>
相关推荐
阿明的小蝴蝶1 小时前
自定义桌面应用、内置系统应用与镜像
前端
代码搬运媛1 小时前
原生websocket 双工demo
前端
limit00751 小时前
三维 WebGIS 工具选型:加载、样式、分析、交付,一套界面能做完什么
android·前端·web3·安卓·webgl
全栈项目管理程序猿1 小时前
ArcGIS JS 基础教程(35):体元系列 - VoxelLayer 时间维度动画
前端·javascript
mantou1322 小时前
从纯 CSR 到 SSR:用 WebComponents 做一个 Next.js 式框架 Gemkit
前端·全栈·web components
cidy_982 小时前
深度拆解任务工作台架构:左右分栏、队列管理、实时刷新全方案
前端
Linye2 小时前
告别“PRD 与原型两张皮”!我们把可交互原生组件直接嵌进了需求文档,还能双向回写 Markdown
前端
deli0072 小时前
RSA 为什么公开公钥也解不开?2 个质数拆开实测一遍
前端
ModyQyW2 小时前
vite-plugin-uni-pages 0.6:统一扫描规则,开发期少重启
前端·uni-app