1. 为什么你需要一个层叠轮播图?

如果你做过微信小程序,尤其是电商类的小程序,肯定对轮播图不陌生。传统的轮播图就是一张张图片平铺切换,虽然实用,但总觉得少了点视觉冲击力,用户看久了容易审美疲劳。我接手过好几个电商项目,产品经理总是提需求:“这个轮播图能不能做得更‘炫’一点?让用户一眼就看到主推商品。”

这时候,层叠轮播图就成了一个绝佳的选择。它把几张卡片像扑克牌一样层叠起来,只有中间那张是最大、最清晰的,两边的卡片会逐渐缩小并带有一定的偏移。当用户滑动时,卡片会像有惯性一样“流”过去,视觉焦点始终在中间那张图上。这种效果不仅好看,更重要的是,它能非常直观地引导用户的注意力,非常适合用来展示商品的主次关系,比如一个系列里的主打款和搭配款。

你可能在网上搜到过一些方案,有的建议用复杂的CSS 3D变换,有的甚至说要引入第三方动画库。但实测下来,对于微信小程序这种相对轻量的环境,最稳、最兼容的方案,还是基于小程序原生的 swiper组件 进行改造。没错,就是那个我们最熟悉的、用来做基础轮播的swiper。通过一些巧妙的样式动态切换,我们完全可以用它模拟出非常流畅的层叠效果,而且性能开销极小,在老款手机上也能跑得很顺畅。

接下来的内容,我会把我踩过的坑和总结的最佳实践,一步步拆开给你看。即使你之前只写过基础的swiper,跟着我的思路走,也能在半小时内做出一个效果惊艳的动态层叠轮播图。

2. 核心思路拆解:动态样式与层级管理

在开始写代码之前,我们得先把脑子里的“效果图”翻译成小程序能理解的逻辑。层叠效果的核心,其实就是两件事:层级(z-index) 和 变形(transform)。

想象一下五张卡片平铺在桌面上,你想让它们层叠起来。你会把中间那张拿起来放到最上面(z-index最高),同时把左右两边的卡片往中间推一点,并且缩小一些(transform: translateX & scale)。对于更边缘的卡片,则推得更远、缩得更小。这就是我们想要实现的静态效果。

但轮播图是动态的,卡片会滑动。所以,我们不能把样式写死。关键在于:每一张卡片的样式,都不是固定的,而是取决于“它当前是不是中间那张激活的卡片”。当滑动发生时,我们需要迅速判断出新的“中间卡”是谁,然后给所有卡片重新计算并应用一套新的样式规则。

原始文章里提到了一个非常重要的点,也是新手最容易搞混的地方:这里“激活的项”指的是视觉上中间最大的那一项,而不是swiper组件默认的current(最左侧的那一项)。因为我们要显示5张卡片(display-multiple-items=“5”),所以视觉中心是第三张。这个概念的区分至关重要,后面所有计算都是围绕“视觉中心索引”而不是“swiper当前索引”展开的。

我的实现方案可以概括为以下三步,这个流程你在任何类似需求中都可以复用:

  1. 结构搭建:用<swiper>包裹多个<swiper-item>,这是容器。
  2. 样式分层:为不同位置的卡片(中间、左右相邻、更远)预先写好多套CSS样式,定义好它们的缩放大小、水平偏移和层级高度。
  3. 动态裁决:在swiper滑动时,通过一个判断函数,实时为每一个<swiper-item>和它内部的视图“裁决”出应该应用哪一套样式类。

这个“裁决官”就是我们将要编写的WXS函数。它根据当前滑动的状态,告诉每个卡片:“嘿,你现在是老大(中间),请用‘老大’的样式”,或者“你是老大的左护法,请用‘左护法’的样式”。这样,我们就把动态的视觉效果和静态的样式代码解耦了,逻辑非常清晰。

3. 从零开始:搭建基础结构与样式

理论说再多不如动手。我们先来把轮播图的骨架和皮肤搭好。这里我会给出详细的代码和参数解释,你可以直接复制过去修改。

首先是小程序页面的WXML结构。我们使用一个标准的swiper,并开启循环播放(circular)和自动播放(autoplay),同时一次显示5张卡片。

<!-- index.wxml -->
<view class="container">
  <swiper
    class="stack-swiper"
    display-multiple-items="5"
    circular="{{true}}"
    autoplay="{{true}}"
    interval="3000"
    duration="500"
    bindchange="onSwiperChange"
    easing-function="easeOutCubic"
  >
    <block wx:for="{{imageList}}" wx:key="id">
      <swiper-item
        class="swiper-item {{swiperUtils.getItemZIndex(index, activeIndex, imageList.length)}}"
      >
        <view
          class="card {{swiperUtils.getCardClass(index, activeIndex, imageList.length)}}"
          style="background-image: url({{item.url}});"
        >
          <!-- 这里可以放商品标题、价格等 -->
          <text class="card-title">{{item.title}}</text>
        </view>
      </swiper-item>
    </block>
  </swiper>
  <!-- 可选:指示点,由于层叠效果,传统指示点可能不直观,可以自定义 -->
  <view class="custom-indicator">
    <text wx:for="{{imageList}}" wx:key="id" class="indicator-dot {{index === activeIndex ? 'active' : ''}}"></text>
  </view>
</view>

注意看swiper-item和内部view.card的class属性,它们分别调用了两个WXS方法:getItemZIndex和getCardClass。这就是我们前面说的“裁决函数”,它们负责动态返回样式类名。activeIndex是我们自己维护的数据,代表视觉中心卡片的索引,不是swiper的current。

接下来是WXSS样式,这是实现层叠视觉效果的重头戏。我们需要定义多套样式类,分别对应卡片的不同状态。

/* index.wxss */
.container {
  padding: 40rpx 0;
  background-color: #f5f5f5;
}

.stack-swiper {
  width: 100%;
  height: 400rpx; /* 根据你的图片比例调整 */
}

.swiper-item {
  /* swiper-item自带绝对定位,方便我们设置z-index */
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.5s ease-out; /* 让层级切换也有过渡效果 */
}

/* 层级类:通过WXS动态添加 */
.z-index-3 {
  z-index: 300; /* 中间激活项,最高 */
}
.z-index-2 {
  z-index: 200; /* 左右相邻项,次高 */
}
.z-index-1 {
  z-index: 100; /* 更边缘的项,最低 */
}

/* 卡片基础样式 */
.card {
  width: 80%; /* 中间卡片的基准宽度 */
  height: 100%;
  border-radius: 16rpx;
  background-size: cover;
  background-position: center;
  box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.15);
  display: flex;
  align-items: flex-end;
  padding: 20rpx;
  box-sizing: border-box;
  transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); /* 平滑的缓动函数 */
}

/* 卡片状态样式类:通过WXS动态添加 */
.card-active {
  /* 中间激活的卡片:最大,无偏移 */
  transform: scale(1);
  opacity: 1;
}
.card-left {
  /* 激活卡片左侧的卡片:缩小,向右偏移 */
  transform: scale(0.85) translateX(35%);
  opacity: 0.9;
}
.card-right {
  /* 激活卡片右侧的卡片:缩小,向左偏移 */
  transform: scale(0.85) translateX(-35%);
  opacity: 0.9;
}
.card-far {
  /* 离激活卡片较远的卡片:缩得更小,偏移更远 */
  transform: scale(0.7) translateX(75%); /* 方向由WXS逻辑决定 */
  opacity: 0.6;
}

.card-title {
  color: white;
  font-size: 32rpx;
  font-weight: bold;
  text-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.5);
}

/* 自定义指示点样式 */
.custom-indicator {
  display: flex;
  justify-content: center;
  margin-top: 30rpx;
}
.indicator-dot {
  width: 12rpx;
  height: 12rpx;
  border-radius: 50%;
  background-color: #ccc;
  margin: 0 8rpx;
  transition: background-color 0.3s;
}
.indicator-dot.active {
  background-color: #07c160; /* 微信绿色 */
  transform: scale(1.2);
}

在这套样式里,.card-active, .card-left, .card-right, .card-far 控制了卡片的视觉形态(大小、位置、透明度),而 .z-index-1/2/3 控制了它们的堆叠顺序。通过组合这两组类,我们就能精确控制每一张卡片在每一时刻的状态。

4. 注入灵魂:编写WXS动态逻辑

WXS是小程序里一种类似于JavaScript的脚本语言,但它运行在视图层,可以高效地处理数据到视图的映射逻辑,非常适合我们这种需要频繁根据数据计算样式的场景。我们将把核心的“裁决逻辑”写在WXS模块里。

首先,在WXML文件顶部引入WXS模块:

<!-- index.wxml 顶部 -->
<wxs module="swiperUtils" src="./utils.swiper.wxs"></wxs>

然后,我们创建这个 utils.swiper.wxs 文件。这里是整个效果最核心的部分,请仔细看我的注释:

// utils.swiper.wxs

// 工具函数:计算两个索引在循环列表中的“最短距离”
var getCyclicDistance = function(index, curIndex, length) {
  var directDist = Math.abs(index - curIndex);
  var cyclicDist = length - directDist;
  // 取最小值,解决循环列表首尾相邻的问题
  return Math.min(directDist, cyclicDist);
}

// 主函数:判断卡片应应用哪种视觉样式类
var getCardClass = function(index, curIndex, length) {
  var idx = parseInt(index);
  var cur = parseInt(curIndex);
  var len = parseInt(length);

  // 1. 如果是当前激活项(视觉中心)
  if (idx === cur) {
    return 'card-active';
  }

  // 2. 处理循环列表的首尾相邻特殊情况
  // 例如:总共5项,激活项是第4项(index=3),那么第0项应该是它的“右侧”
  var distance = getCyclicDistance(idx, cur, len);

  // 如果最短距离是1,说明是紧邻的项
  if (distance === 1) {
    // 判断是左邻还是右邻,需要考虑循环
    var diff = (idx - cur + len) % len; // 保证结果为正数
    if (diff === 1 || diff === (len - 1) && idx === 0) {
      // 常规右侧 或 激活项在末尾而首项在右侧
      return 'card-right';
    } else {
      // 常规左侧 或 激活项在开头而末项在左侧
      return 'card-left';
    }
  }

  // 3. 如果距离大于1,则是较远的项
  // 这里可以进一步细分,比如距离为2的用一种样式,但本例统一用‘far’
  // 你也可以根据diff判断far项的偏移方向,让最左边的往左偏,最右边的往右偏
  var diff = (idx - cur + len) % len;
  if (diff === 2 || (diff === (len - 2) && idx < cur)) {
    // 举例:激活项在中间,索引为2,那么索引0和4都是‘far’,但0应该向左偏,4向右偏
    // 我们可以再返回更细的类,如‘card-far-left’和‘card-far-right’
    // 本例为简化,返回‘card-far’,并在CSS中根据情况写不同的transform
    // 更严谨的做法是让WXS返回一个包含方向信息的类,比如:
    // return diff < len/2 ? 'card-far-right' : 'card-far-left';
    return 'card-far';
  }
  // 对于距离更远的(当length>5时),可以返回另一个类,比如‘card-hidden’降低透明度
  return 'card-far';
}

// 主函数:判断swiper-item应应用哪个层级类
var getItemZIndex = function(index, curIndex, length) {
  var idx = parseInt(index);
  var cur = parseInt(curIndex);
  var len = parseInt(length);

  // 激活项层级最高
  if (idx === cur) {
    return 'z-index-3';
  }

  var distance = getCyclicDistance(idx, cur, len);

  // 紧邻的左右两项层级次高
  if (distance === 1) {
    return 'z-index-2';
  }

  // 其他项层级最低(也可以不给类,默认层级)
  return 'z-index-1';
}

// 暴露方法给WXML调用
module.exports = {
  getCardClass: getCardClass,
  getItemZIndex: getItemZIndex
};

这段代码的精华在于 getCyclicDistance 函数和对于循环列表(circular)首尾相接情况的处理。因为轮播图是循环的,当激活项在最后一张时,第一张卡片实际上应该是它的“右侧”卡片。我们的逻辑必须能正确识别这种关系。我通过 (idx - cur + len) % len 这种计算方式,将索引差转换到一个固定的方向上,从而能可靠地判断左右关系。

5. 联动与驱动:JS逻辑处理滑动事件

WXS帮我们处理了视图层的样式判断,但驱动这一切变化的“发动机”还在JS逻辑层。我们需要在swiper的 bindchange 事件中,更新那个代表视觉中心索引的 activeIndex。

回到 index.js 的Page定义中:

// index.js
Page({
  data: {
    imageList: [
      { id: 1, url: '/images/product1.jpg', title: '新款智能手表' },
      { id: 2, url: '/images/product2.jpg', title: '无线降噪耳机' },
      { id: 3, url: '/images/product3.jpg', title: '旗舰智能手机' },
      { id: 4, url: '/images/product4.jpg', title: '便携蓝牙音箱' },
      { id: 5, url: '/images/product5.jpg', title: '超薄笔记本电脑' },
      // 可以继续添加,swiper会循环展示
    ],
    activeIndex: 2, // 初始化时,视觉中心是第三项(索引2)
  },

  onLoad: function() {
    // 可以在这里进行网络请求获取轮播图数据
  },

  // swiper滑动事件处理函数
  onSwiperChange: function(e) {
    const current = e.detail.current; // swiper当前最左侧项的索引
    const total = this.data.imageList.length;

    // !!! 核心计算 !!!
    // 因为我们显示5项(display-multiple-items=5),视觉中心是中间项,即左起第3项。
    // 所以视觉中心索引 = 最左侧索引 + 2。
    // 同时要考虑循环,所以需要取模。
    let newActiveIndex = (current + 2) % total;

    // 更新数据,触发WXML重新渲染,WXS函数会根据新的activeIndex重新计算每个卡的样式
    this.setData({
      activeIndex: newActiveIndex
    });

    // 这里可以同时更新自定义指示点的高亮
    // 指示点通常高亮activeIndex,但如果你想要传统效果,可以高亮current
  },

  // 如果需要点击卡片跳转
  onCardTap: function(e) {
    const index = e.currentTarget.dataset.index;
    const item = this.data.imageList[index];
    console.log('点击了卡片:', item.title);
    // wx.navigateTo({ url: '/pages/detail/detail?id=' + item.id });
  },
});

最关键的就是 onSwiperChange 函数里的 (current + 2) % total 这一行。它完成了从 swiper.current 到我们自定义的 视觉中心索引 的转换。这个“+2”就是由 display-multiple-items="5" 决定的(中间项索引偏移量 = (显示数量-1)/2)。如果你改成显示3项,这里就应该 +1。

至此,一个能够自动播放、循环滚动、具有层叠视觉效果的轮播图就基本完成了。你可以运行小程序看看效果,卡片应该会平滑地滑动,并且始终保证中间的卡片是最大最清晰的。

6. 进阶优化与常见问题排查

基础功能跑通后,我们通常会遇到一些体验细节上的问题。这里分享几个我实战中遇到的坑和优化方案。

问题一:滑动动画生硬,不够跟手 默认的swiper动画可能比较机械。我们可以通过调整swiper的 easing-function 和 duration 属性来改善。上面WXML中我用了 easeOutCubic 和 500ms 的动画时长,这会让滑动有一个先快后慢的缓动效果,更接近物理世界的运动。小程序提供了一些内置的缓动函数,如 linear、easeInCubic、easeOutCubic 等,你可以多试试找到最舒服的一个。

问题二:在快速滑动或自动播放时,边缘卡片闪烁或跳动 这通常是由于WXS计算样式和swiper内部动画帧不同步,或者样式过渡(transition)冲突导致的。我的解决方案是:

  1. 确保WXS逻辑正确处理了所有边界情况(特别是循环衔接处),参考我上面WXS代码中对 len-1 和 0 的特殊判断。
  2. 检查CSS中的 transition 属性。我只在 .card 和 .swiper-item 上设置了过渡,并且属性明确指定为 transform 和 opacity,避免不必要的属性参与动画。有时候去掉 all,明确指定属性能避免奇怪的问题。
  3. 可以尝试稍微增加 duration(例如到550ms),让swiper的滑动动画慢一点点,给样式重计算留出更多时间。

问题三:如何添加手动切换按钮(如左右箭头)? 原始文章末尾提到了这个问题。一旦你显式地设置了swiper的 current 属性,就需要更精细地控制它和 bindchange 事件的触发关系,否则用户点击箭头和swiper自身动画可能产生冲突。 我的做法是:不再依赖 bindchange 来更新 activeIndex,而是完全由 current 驱动。

  1. 在data中定义 swiperCurrent,并绑定到swiper的 current 属性。
  2. 左右箭头的点击事件,直接计算目标索引并更新 swiperCurrent。
  3. 同时,在 swiperChange 事件中,也更新 swiperCurrent(为了响应手动滑动)。
  4. activeIndex 的计算改为依赖于 swiperCurrent,可以在 observer 监听器中或每次更新 swiperCurrent 后同步计算。
// 示例代码片段
Page({
  data: {
    swiperCurrent: 0,
    activeIndex: 2,
  },
  // 点击右箭头
  onNext() {
    const total = this.data.imageList.length;
    const next = (this.data.swiperCurrent + 1) % total;
    this.setData({
      swiperCurrent: next,
      activeIndex: (next + 2) % total // 同步更新视觉中心索引
    });
  },
  // swiper滑动事件
  onSwiperChange(e) {
    const current = e.detail.current;
    this.setData({
      swiperCurrent: current,
      activeIndex: (current + 2) % this.data.imageList.length
    });
  }
})

问题四:图片加载慢导致布局抖动 层叠效果依赖于图片容器的尺寸。如果图片是网络图片且未指定宽高,加载过程中容器可能会塌陷,导致样式错乱。务必在 .card 的样式中设置固定的 width 和 height,或者使用比例固定的容器(如 padding-top: 56.25% 实现16:9)。也可以先给一个统一的背景色或占位图。

7. 适配不同场景与设计变体

掌握了基础实现后,你就可以像搭积木一样,创造出各种变体来适应不同的产品需求。

场景一:电商首页焦点图 这是最常用的场景。除了展示图片,可以在 .card-active 的样式里,增加一个商品标签动画,比如一个“Hot”徽章轻微上下浮动。还可以在非激活卡片上覆盖一层淡淡的黑色半透明层,并加上“查看详情”的文字,点击后跳转。这样能进一步引导用户与轮播图互动。

场景二:品牌故事或功能亮点介绍 如果轮播图的内容是图标+文字,可以调整层叠效果。比如,激活项放大并变为彩色,非激活项缩小并变为灰色。这时,修改的不再是 translateX,而是 scale 和 filter: grayscale()。记得在小程序中使用CSS滤镜时,要查看基础库兼容性。

场景三:显示3项或7项 我们的代码是围绕显示5项编写的。如果要显示3项(display-multiple-items="3"),你需要做以下调整:

  1. WXML中修改 display-multiple-items="3"。
  2. JS中计算 activeIndex 的公式改为 (current + 1) % total。
  3. WXS中 getCardClass 和 getItemZIndex 的逻辑需要调整,因为“相邻项”的定义变了,可能只有左右1项有特殊样式,没有“far”项了。
  4. CSS中需要新增或修改对应的样式类,例如 .card-left 和 .card-right 的 translateX 值应该减小,因为空间没那么大了。

一个通用的技巧是,将“显示数量”和“视觉中心偏移量”也作为参数传到WXS函数中,这样你的组件就会更加灵活和可配置。

更多推荐