实现一个具有拖拽功能的跑马灯

avatar
前端开发工程师 @bigo

file

本文首发于:github.com/bigo-fronte… 欢迎关注、转载。

需求分析:

  • 需要提供一个组件,可以根据子元素的宽度,自动决定是否进行滚动(跑马灯功能)
  • 滚动的时候,有首尾相连的效果,即滚动到队尾的时候,队头同时从另一端出现(即循环播放,中间不断开)

Kapture 2021-04-26 at 20 20 48

  • 滚动的同时,可以通过触摸拖动元素(即可向左也可向右拖拽)
  • 考虑多语言的阅读方向,如汉语是从左往右阅读,则marquee从右往左滚动,阿拉伯语是从右往左阅读,marquess从左往右滚动
  • 利用vue开发

实现

1. 根据子元素宽度决定是否滚动

  • 组件结构为一个顶层div作为容器(container),里面一个次级div作为滚动区域(scroller),最后在滚动区域内部通过slot接收滚动元素
  • 考虑组件为横向滚动,设置内部滚动区域的div为flex布局,从而使传入的元素为横向排列
  • 具体页面结构如下:
<div class="horizontal-scroller" ref="myContainer">
    <div class="__scroller-panel" ref="myScroller">
        <slot></slot>
    </div>
</div>

/* style */

.horizontal-scroller {
  overflow: hidden;
  width: auto;
  max-width: 600px;
  display: inline-block;
  white-space: nowrap;
  position: relative;
  .__scroller-panel {
    white-space: nowrap;
    width: auto;
    display: inline-flex;
  }
}

  • 注意到这里containner和scroller的width都设为auto,为的是让他们能随内容自动增加宽度,但是containner有个最大宽度,超过了之后会将内部元素隐藏,另外containner与scroller都设置了内部元素不换行white-space: nowrap,保证所有元素都能排列在一行里
  • 最后scroller设置inline-flex布局,注意这里设置的是inline-flex,可以让scroller的宽度随内容自动增加,而如果设置为flex,scroller的最大宽度会等于他的父元素宽度,这样scroller就无法被内部元素撑开,从而无法获得内部元素的完整宽度
  • 接着通过读取元素的clientWidth,以及容器的clientWidth,来判断当前是否允许滚动
  • 由于使用的是vue slot的形式,需要将判断逻辑放在nexttick里面执行,以确保判断的时候,元素已经得到渲染,该初始化判断可以放在mounted里,代码如下:
mounted() {
  this.refresh();
}
refresh() {
  this.resetTimmer(); // 重设定时器
  this.$nextTick(() => {
    if (!this.$refs.myScroller) return;
    this.currentTranslateX = 0; // 当前元素滚动的距离
    this.containerWidth = 0; // 容器宽
    this.scrollerWidth = 0; // 元素宽
    this.marquee = false; // 是否滚动
    const {
      clientWidth: scrollerWidth
    } = this.$refs.myScroller;
    const { clientWidth: containerWidth } = this.$refs.myContainer;
    if (scrollerWidth > containerWidth + 2) { // 兼容某些机型
      this.marquee = true;
      this.containerWidth = containerWidth;
      this.scrollerWidth = scrollerWidth;
      this.currentTranslateX = 0; // 初始位置
      this.processMarquee();
    }
  });
}
  • 在mounted中,首先清空了定时器,该定时器的作用是用来使元素滚动的,该逻辑后面会提及,这里只需知道其作用即可
  • 清空定时器后,初始化了各种变量,然后读取滚动区域宽度,以及容器宽度,由于某些机型上即使还没到max-wdith,元素宽也会比容器宽多一点,所以这里做了一点兼容处理(即判断条件里的+2);
  • 当判断容器宽比元素宽小的时候,使标志marquee为true,表明其可以滚动,同时记录当前元素宽以及容器宽,并启动滚动效果,即最后的processMarquee,其代码如下:
processMarquee() {
  if (!this.marquee) return;
  this.timmer && clearTimeout(this.timmer); // 意外调用两次的时候保证只执行一次
  this.timmer = setTimeout(() => {
    if (!this.isNeedReset(1, true)) {
      this.currentTranslateX += 1;
    }
    this.processMarquee();
  }, 20);
},
  • 首先,只有在判断可以滚动的情况下才会执行下面的逻辑,并且为了保证只执行一次,会有一个清除计时器的动作
  • 其后就是通过settimout,不断给currentTranslateX,即当前元素移动量加一,来移动元素
  • 这里的isNeedReset,是用来判断,是否需要挪动当前元素(即将元素挪到队列头部或尾部),这样视觉上才会看起来是从左到右循环轮播,该部分的具体逻辑会在后面讨论,这里只需要知道,如果不需要重置位置,则给位移加一,如果需要重置位置,则跳过位移加一,在isNeedReset里会执行重置位置的操作
  • 接下来看currentTranslateX,该值会被用来计算元素的偏移量,然后作为元素的style,通知浏览器去移动元素,相关代码如下:
<div class="horizontal-scroller" ref="myContainer">
    <div class="__scroller-panel" ref="myScroller" :style="positionStyle">
        <slot></slot>
    </div>
</div>
/* script */
computed: {
    positionStyle() {
      return { transform: `translateX(${-this.currentTranslateX}px)` };
    },
}

循环轮播

  • 目前,容器里,只有一个scroller元素,我们必须等元素完全从容器的头部或尾部消失之后,才能通过isNeedReset来重置元素的位置,达到轮播的效果
  • 如果要在元素未完全消失之前,就使元素的头部紧跟元素的尾部出现,那么我们至少需要有两个scroller元素,另一个作为第一个元素的clone元素
  • 这里我们称第一个元素为原元素,复制出来的元素为clone元素
  • 复制clone元素的方法有很多,最常用的是通过node.cloneNode来复制,但在vue里,通过这种方式复制的元素无法响应绑定在原元素上面的点击等事件,所以在vue里,可以利用多个slot就有多个拷贝的特性来复制元素,如下:
<div class="horizontal-scroller" ref="myContainer">
    <div class="__scroller-panel" ref="myScroller" :style="positionStyle">
        <slot></slot>
    </div>
    <div class="__scroller-panel-clone" ref="myScrollerClone" :style="positionStyleClone">
        <slot></slot>
    </div>
</div>
/* style */
.horizontal-scroller {
  overflow: hidden;
  width: auto;
  max-width: 600px;
  display: inline-block;
  white-space: nowrap;
  position: relative;
  .__scroller-panel
  .__scroller-panel-clone { // 新增
    white-space: nowrap;
    width: auto;
    display: inline-flex;
  }
  .__scroller-panel-clone { // 新增
    position: absolute;
    top: 0;
    left: 0;
  }
}
/* script */
computed: { // 新增
    positionStyleClone() {
      return { transform: `translateX(${-this.currentTranslateXClone}px)` };
    },
}
  • clone元素与原元素样式基本一致,但clone元素为绝对定位,这样,容器才不会额外被clone元素撑开宽度
  • 目前clone元素和原元素是完全重叠的状态,我们需要把clone元素移开,排列展示,故在初始化阶段加入下面的逻辑
refresh() {
  this.resetTimmer();
  this.$nextTick(() => {
    if (!this.$refs.myScroller) return;
    this.currentTranslateX = 0;
    this.containerWidth = 0;
    this.scrollerWidth = 0;
    this.paddingStyle = {}; // 新增,初始化padding样式
    this.isCloneBeofre = false; // 新增,clone元素是在原元素前面还是后面
    this.marquee = false;
    this.$nextTick(() => { // 新增,上面paddingStyle还原真实宽度后,下个nextick才能取到实际宽度
       const {
          clientWidth: scrollerWidth
        } = this.$refs.myScroller;
        const { clientWidth: containerWidth } = this.$refs.myContainer;
        if (scrollerWidth > containerWidth + 2) {
          const paddingWidth = containerWidth * 0.1; // 新增,marquee时复制一份元素放在队尾,间隔为容器的20%,各自分得10%
          this.paddingStyle = { padding: `0 ${paddingWidth}px` };
          this.marquee = true;
          this.containerWidth = containerWidth;
          this.scrollerWidth = scrollerWidth + 2 * paddingWidth; // 修改
          this.currentTranslateX = paddingWidth - 2; // 修改
          this.processMarquee();
        }
      }); 
    });
}
  • 这里新增了一个isCloneBefore的标志,将会用来计算clone元素的偏移量,代表着clone元素是否在原元素的前面,这里clone元素的位移将完全依赖与原元素,具体逻辑将会在后面提及
  • 另外,考虑到轮播中,原元素以及clone元素间需要稍微分隔开,以更好区分一组数据,我们在这里设置了一个padding
  • 因为额外设置了padding,在获取真实元素宽度之前,需要将padding重置,并在重置后等待一个nexttick,才能获取到实际的元素宽度
  • padding样式的应用:
<div class="horizontal-scroller" ref="myContainer">
    <div
        class="__scroller-panel"
        ref="myScroller"
        :style="{ ...paddingStyle, ...positionStyle }"
    >
        <slot></slot>
    </div>
    <div
        class="__scroller-panel-clone"
        ref="myScrollerClone"
        :style="{ ...paddingStyle, ...positionStyleClone }"
    >
        <slot></slot>
    </div>
</div>
  • 回到clone元素,在计算其偏移量之前,我们先分析一下clone元素和原元素的对应关系,由于可滚动的时候,元素的头尾不可能同时在容器可视区域内(元素需比容器宽),同时考虑clone元素位置,所以元素位置总结有以下四种场景: A. 原元素的头部在可视范围里,则clone元素的尾部也在可视范围里,且clone元素在原元素前面 B. 原元素的尾部在可视范围里,则clone元素的头部也在可视范围里,且clone元素在原元素后面 C. 可视范围只有原元素 D. 可视范围只有clone元素
  • 当元素从右往左滚动的时候,位置变化的顺序是C -> B -> D -> A,由于元素处在C和D这四种状态的时候,原元素与clone元素的前后顺序是随意的,都不影响展示,那么我们可以假设C状态时,clone元素在原元素后面,D状态的时候,clone元素在原元素前面,总结如下: A. 原元素的头部在可视范围里,则clone元素的尾部也在可视范围里,且clone元素在原元素前面 B. 原元素的尾部在可视范围里,则clone元素的头部也在可视范围里,且clone元素在原元素后面 C. 可视范围只有原元素,clone元素在原元素后面 D. 可视范围只有clone元素,clone元素在原元素前面

无标题绘图

  • 这样处理的好处是C到B,以及D到A,这两段位置的变化,无需变化clone元素以及原元素的位置关系,故位置会发生变化的临界点是:
    • B到D,或D到B
    • A到C,或C到A
  • 据此我们可以写出之前提及的isNeedReset方法:
isNeedReset(step, onMarquee = false) {
   const newCurrentX = onMarquee
       ? this.currentTranslateX + step
       : this.startTranslateX + step;
   if (this.currentTranslateX < 0 && newCurrentX >= 0) {
       // 从A到C, clone不可见,挪到原型后面
       console.log(1);
       this.currentTranslateX = newCurrentX;
       this.isCloneBeofre = false;
       return true;
   }
   if (this.currentTranslateX > 0 && newCurrentX <= 0) {
       // 从C到A, clone可见,挪到原型前面
       console.log(2);
       this.currentTranslateX = newCurrentX;
       this.isCloneBeofre = true;
       return true;
   }
   if (
       this.currentTranslateX < this.scrollerWidth &&
       newCurrentX >= this.scrollerWidth
   ) {
       // 从B到D,原型不可见,挪到clone后面
       console.log(3);
       this.currentTranslateX = newCurrentX - this.scrollerWidth - this.scrollerWidth;
       this.isCloneBeofre = true;
       return true;
   }
   if (
       this.currentTranslateX > -this.scrollerWidth &&
       newCurrentX <= -this.scrollerWidth
   ) {
       // 从D到B,原型可见,挪到clone前面
       console.log(4);
       this.currentTranslateX = this.scrollerWidth - (-this.scrollerWidth - newCurrentX);
       this.isCloneBeofre = false;
       return true;
   }
   return false;
}
  • onMarquee是用来区分当前是手动拖动还是是自动滚动状态的,先假设onMarquee都是true,则通过对比变化前后的currentTranslateX,可以区分出四种临界状态,并设置对应的isCloneBeofre标志
  • 在B到D以及D到B的状态变化时,由于需要挪动原元素,需要重新计算新的currentTranslateX,挪动的距离是两个元素宽度(即± scrollerWidth x 2)
  • 知道了clone元素和原元素的前后关系后,就可以计算clone元素的位移(currentTranslateXClone)了,clone元素和原元素的位移偏差是一个元素宽度(即± scrollerWidth)
computed: {
    currentTranslateXClone() {
      if (this.isCloneBeofre) {
        return this.currentTranslateX + this.scrollerWidth;
      }
      return this.currentTranslateX - this.scrollerWidth;
    }
}
  • 到这里,我们就实现了一个可循环轮播的跑马灯,接下来我们需要考虑的是拖拽的逻辑

触摸拖动

  • 触发的区域为整个容器,故在容器上面加上监听
<div
    class="horizontal-scroller"
    ref="myContainer"
    @touchstart="onStart"
    @touchend="onEnd"
    @touchmove.prevent="onMove"
    @touchcancel="onEnd"
    @mousedown="onStart"
    @mousemove.prevent="onMove"
    @mouseup="onEnd"
    @mousecancel="onEnd"
    @mouseleave="onEnd"
>
    <!-- 省略 --!>
</div>
  • 同时触摸的时候,停止滚动
onStart(e) {
    if (!this.marquee) return; // 没有跑马灯效果时拖动无效
    const point = e.touches ? e.touches[0] : e;
    this.startX = point.pageX; // 点击的位置
    this.startTranslateX = this.currentTranslateX; // 点击时的位移
    this.stop = true; // 暂时没有作用,仅仅是个标志,代表此时暂停,真正的停止滚动是下面的方法
    this.stopMarquee();
}
stopMarquee() {
    clearTimeout(this.timmer);
    this.timmer = null;
}
  • 由于拖动是一个持续的过程,而且可能来回拖动,所以拖动的时候需要点击的位置,以及初始位移,在onMove方法中,结合这两个变量以及当前触摸点位置来计算位移值:
onMove(e) {
    if (this.gestureTimmer || !this.marquee) return;
    const point = e.touches ? e.touches[0] : e;
    const diffX = Math.round(this.startX - point.pageX); // 小数会影响渲染效率
    if (!this.isNeedReset(diffX)) {
        this.currentTranslateX = this.startTranslateX + diffX;
    } else {
        this.startTranslateX = this.currentTranslateX;
        this.startX = point.pageX;
    }
    this.gestureTimmer = setTimeout(() => {
        this.gestureTimmer = null;
    }, 20);
}
  • 拖动过程中,加了节流(gestureTimmer),每20ms才可以触发一次
  • 计算start和move之间的位移,加上start时记录的startTranslateX,就是需要更新的位移值
  • 回到isNeedReset中可以看到,但onMarquee是false的时候,下一个位移值也是按上面的方法计算的,但与自动滚动时不同的是,这里判断重置位置了之后,需要重新设置当前的startTranslateX以及startX,这是因为重置位置后,位移发生了变化,需要按当前触摸点坐标刷新这两个值,后续的计算才准确
  • 最后触摸结束的时候重启滚动
onEnd() {
    if (!this.marquee) return;
    this.stop = false;
    this.processMarquee();
}
  • 注意,这几个方法都是只有marquee为true,也就是最开始判断元素宽度大于容器时,才会执行,也就是当容器宽度比元素宽度大时,拖动是无效的
  • 最后补充一下初始化方法中的resetTimmer,也很简单,就是把上述提到的滚动定时器,以及拖动定时器清空
resetTimmer() {
    clearTimeout(this.timmer);
    clearTimeout(this.gestureTimmer);
    this.timmer = null;
    this.gestureTimmer = null;
}

考虑多语言的情况

  • 上述主要实现了满足从左往右阅读习惯的跑马灯,而对于从右往左阅读的语言,还需要满足,元素从右往左排列,跑马灯从左往右自动移动
  • 由于此处,容器中是以flex样式进行布局的,所以只需要在body中加上direction: rtl即可让容器内元素从右往左排列,同时由于clone元素采用的是绝对定位,在从右往左阅读习惯的语言中,需要将定位left: 0改为right: 0,才能使clone元素和原元素在初始状态时是重叠的
body {
    direction: rtl;
}

.__scroller-panel-clone { // 新增
    position: absolute;
    top: 0;
    right: 0;
  }
  • 接下来需要改变的是运动方向,即变量positionStylepositionStyleClone,我们增加一个变量right用来标识当前多语言的阅读方向,当right为true的时候,我们需要将元素的运动方向反过来,即positionStylepositionStyleClone中的translate方向要相反,如下:
computed: { // 新增
    positionStyle() {
      const translateX = this.right ? this.currentTranslateX : -this.currentTranslateX;
      return { transform: `translateX(${translateX}px)` };
    },
    positionStyleClone() {
      const translateX = this.right ? this.currentTranslateXClone : -this.currentTranslateXClone;
      return { transform: `translateX(${translateX}px)` };
    },
}
  • 改变了运动方向后,自动滚动部分的运动就已经完成了,随着currentTranslateX的增加,元素现在会从左往右移动
  • 但会发现触摸拖动的运动会反过来,即当用户从左往右拖动的时候,元素会从右往左运动,这是因为拖动的位移量是通过触摸屏幕过程中触摸点的坐标位移计算出来的,与自动滚动时的位移量1含义不同,拖动的位移计算量在从左往右语境以及从右往左语境中是相同的,不会因为语境而变化,而自动滚动的1在不同语境中,代表了不同的方向,如在从右往左的语境中,代表向右移动一个px
  • 所以最后我们需要改变的是触摸拖动部分的位移量计算方式,而改变的方式为取其负值即可,这是因为不同语境中positionStyle的取值方式是相反的,从左往右的语境中,currentTranslateX的增加会使元素向左运动,而从右往左的语境中,currentTranslateX的增加会使元素向右运动,所以若想元素运动方向与触摸拖动方向相同,则计算坐标变化diffX需要取相反值,即
onMove(e) {
    if (this.gestureTimmer || !this.marquee) return;
    const point = e.touches ? e.touches[0] : e;
    let diffX = Math.round(this.startX - point.pageX);
    if (this.right) diffX = -diffX;
    if (!this.isNeedReset(diffX)) {
        this.currentTranslateX = this.startTranslateX + diffX;
    } else {
        this.startTranslateX = this.currentTranslateX;
        this.startX = point.pageX;
    }
    this.gestureTimmer = setTimeout(() => {
        this.gestureTimmer = null;
    }, 20);
}
  • 这样,一个满足不同阅读习惯的循环轮播跑马灯就实现了,同时其也支持拖拽效果

总结

市面上其实有很多现成的,功能更强大的跑马灯可供选择,但通过自己实现这样一个功能,可以帮助自己更好地了解其他工具的内部实现逻辑。而对于此类问题,往往需要将复杂的问题分离成简单的单个小问题,如分析原元素以及clone元素之间的位置关系,来将一个复杂的场景分成四个具体的临界点,而后面的多语言实现部分,也是将具体实现分为了轮播,以及拖拽两种情景,这种思维方式才是真正需要掌握的。

欢迎大家留言讨论,祝工作顺利、生活愉快!

我是bigo前端,下期见。