Skip to content

Latest commit

 

History

History
285 lines (207 loc) · 11.4 KB

carousel.md

File metadata and controls

285 lines (207 loc) · 11.4 KB

实现轮播图组件

点击关注本公众号获取文档最新更新,并可以领取配套于本指南的 《前端面试手册》 以及最标准的简历模板.

轮播图基本原理

轮播图(Carousel),在 Antd 中被称为走马灯,可能是前端开发者最常见的组件之一了,不管是在 PC 端还是在移动端我们总能见到他的身影.

那么我们通常是如何使用轮播图的呢?Antd 的代码如下

  <Carousel>
    <div><h3>1</h3></div>
    <div><h3>2</h3></div>
    <div><h3>3</h3></div>
    <div><h3>4</h3></div>
  </Carousel>

问题是我们在Carousel中放入了四组div为什么一次只显示一组呢?

图中被红框圈住的为可视区域,可视区域的位置是固定的,我们只需要移动后面div的位置就可以做到1 2 3 4四个子组件轮播的效果,那么子组件2目前在可视区域是可以被看到的,1 3 4应该被隐藏,这就需要我们设置overflow 属性为 hidden来隐藏非可视区域的子组件.

因此就比较明显了,我们设计一个可视窗口组件Frame,然后将四个 div共同放入幻灯片组合组件SlideList中,并用SlideItem分别将 div包裹起来,实际代码应该是这样的:

 <Frame>
    <SlideList>
        <SlideItem>
            <div><h3>1</h3></div>  
        </SlideItem>
        <SlideItem>
            <div><h3>2</h3></div>  
        </SlideItem>
        <SlideItem>
            <div><h3>3</h3></div>  
        </SlideItem>
        <SlideItem>
            <div><h3>4</h3></div>  
        </SlideItem>
    </SlideList>
  </Frame>

我们不断利用translateX来改变SlideList的位置来达到轮播效果,如下图所示,每次轮播的触发都是通过改变transform: translateX()来操作的

轮播图基础实现

搞清楚基本原理那么实现起来相对容易了,我们以移动端的实现为例,来实现一个基础的移动端轮播图.

首先我们要确定可视窗口的宽度,因为我们需要这个宽度来计算出SlideList的长度(SlideList的长度通常是可视窗口的倍数,比如要放三张图片,那么SlideList应该为可视窗口的至少3倍),不然我们无法通过translateX来移动它.

我们通过getBoundingClientRect来获取可视区域真实的长度,SlideList的长度那么为:

slideListWidth = (len + 2) * width(len 为传入子组件的数量,width 为可视区域宽度)

至于为什么要+2后面会提到.

  /**
   * 设置轮播区域尺寸
   * @param x
   */
  private setSize(x?: number) {
    const { width } = this.frameRef.current!.getBoundingClientRect()
    const len = React.Children.count(this.props.children)
    const total = len + 2

    this.setState({
      slideItemWidth: width,
      slideListWidth: total * width,
      total,
      translateX: -width * this.state.currentIndex,
      startPositionX: x !== undefined ? x : 0,
    })
  }

获取到了总长度之后如何实现轮播呢?我们需要根据用户反馈来触发轮播,在移动端通常是通过手指滑动来触发轮播,这就需要三个事件onTouchStart onTouchMove onTouchEnd.

onTouchStart顾名思义是在手指触摸到屏幕时触发的事件,在这个事件里我们只需要记录下手指触摸屏幕的横轴坐标 x 即可,因为我们会通过其横向滑动的距离大小来判断是否触发轮播

  /**
   * 处理触摸起始时的事件
   *
   * @private
   * @param {React.TouchEvent} e
   * @memberof Carousel
   */
  private onTouchStart(e: React.TouchEvent) {
    clearInterval(this.autoPlayTimer)
    // 获取起始的横轴坐标
    const { x } = getPosition(e)
    this.setSize(x)
    this.setState({
      startPositionX: x,
    })
  }

onTouchMove顾名思义是处于滑动状态下的事件,此事件在onTouchStart触发后,onTouchEnd触发前,在这个事件中我们主要做两件事,一件事是判断滑动方向,因为用户可能向左或者向右滑动,另一件事是让轮播图跟随手指移动,这是必要的用户反馈.

 /**
   * 当触摸滑动时处理事件
   *
   * @private
   * @param {React.TouchEvent} e
   * @memberof Carousel
   */
  private onTouchMove(e: React.TouchEvent) {
    const { slideItemWidth, currentIndex, startPositionX } = this.state
    const { x } = getPosition(e)

    const deltaX = x - startPositionX
    // 判断滑动方向
    const direction = deltaX > 0 ? 'right' : 'left'

    this.setState({
      direction,
      moveDeltaX: deltaX,
      // 改变translateX来达到轮播组件跟随手指移动的效果
      translateX: -(slideItemWidth * currentIndex) + deltaX,
    })
  }

onTouchEnd顾名思义是滑动完毕时触发的事件,在此事件中我们主要做一个件事情,就是判断是否触发轮播,我们会设置一个阈值threshold,当滑动距离超过这个阈值时才会触发轮播,毕竟没有阈值的话用户稍微触碰轮播图就造成轮播,误操作会造成很差的用户体验.

  /**
   * 滑动结束处理的事件
   *
   * @private
   * @memberof Carousel
   */
  private onTouchEnd() {
    this.autoPlay()
    const { moveDeltaX, slideItemWidth, direction } = this.state
    const threshold = slideItemWidth * THRESHOLD_PERCENTAGE
    // 判断是否轮播
    const moveToNext = Math.abs(moveDeltaX) > threshold

    if (moveToNext) {
        // 如果轮播触发那么进行轮播操作
      this.handleSwipe(direction!)
    } else {
        // 轮播不触发,那么轮播图回到原位
      this.handleMisoperation()
    }
  }

轮播图的动画效果

我们常见的轮播图肯定不是生硬的切换,一般在轮播中会有一个渐变或者缓动的动画,这就需要我们加入动画效果.

我们制作动画通常有两个选择,一个是用 css3自带的动画效果,另一个是用浏览器提供的requestAnimationFrame API

孰优孰劣?css3简单易用上手快,兼容性好,requestAnimationFrame 灵活性更高,能实现 css3实现不了的动画,比如众多缓动动画 css3都束手无策,因此我们毫无疑问地选择了requestAnimationFrame.

双方对比请看张鑫旭大神的CSS3动画那么强,requestAnimationFrame还有毛线用?

想用requestAnimationFrame实现缓动效果就需要特定的缓动函数,下面就是典型的缓动函数

type tweenFunction = (t: number, b: number, _c: number, d: number) => number
const easeInOutQuad: tweenFunction = (t, b, _c, d) => {
    const c = _c - b;
    if ((t /= d / 2) < 1) {
      return c / 2 * t * t + b;
    } else {
      return -c / 2 * ((--t) * (t - 2) - 1) + b;
    }
}

缓动函数接收四个参数,分别是:

  • t: 时间
  • b:初始位置
  • _c:结束的位置
  • d:速度

通过这个函数我们能算出每一帧轮播图所在的位置, 如下:

在获取每一帧对应的位置后,我们需要用requestAnimationFrame不断递归调用依次移动位置,我们不断调用animation函数是其触发函数体内的 this.setState({ translateX: tweenQueue[0], })来达到移动轮播图位置的目的,此时将这数组内的30个位置依次快速执行就是一个缓动动画效果.

  /**
   * 递归调用,根据轨迹运动
   *
   * @private
   * @param {number[]} tweenQueue
   * @param {number} newIndex
   * @memberof Carousel
   */
  private animation(tweenQueue: number[], newIndex: number) {
    if (tweenQueue.length < 1) {
      this.handleOperationEnd(newIndex)
      return
    }
    this.setState({
      translateX: tweenQueue[0],
    })
    tweenQueue.shift()
    this.rafId = requestAnimationFrame(() => this.animation(tweenQueue, newIndex))
  }

但是我们发现了一个问题,当我们移动轮播图到最后的时候,动画出现了问题,当我们向左滑动最后一个轮播图div4时,这种情况下应该是图片向左滑动,然后第一张轮播图div1进入可视区域,但是反常的是图片快速向右滑动div1出现在可是区域...

因为我们此时将位置4设置为了位置1,这样才能达到不断循环的目的,但是也造成了这个副作用,图片行为与用户行为产生了相悖的情况(用户向左划动,图片向右走).

目前业界的普遍做法是将图片首尾相连,例如图片1前面连接一个图片4,图片4后跟着一个图片1,这就是为什么之前计算长度时要+2

slideListWidth = (len + 2) * width(len 为传入子组件的数量,width 为可视区域宽度)

当我们移动图片4时就不会出现上述向左滑图片却向右滑的情况,因为真实情况是:

图片4 -- 滑动为 -> 伪图片1 也就是位置 5 变成了位置 6

当动画结束之后,我们迅速把伪图片1的位置设置为真图片1,这其实是个障眼法,也就是说动画执行过程中实际上是图片4伪图片1的过程,当结束后我们偷偷把伪图片1换成真图片1,因为两个图一模一样,所以这个转换的过程用户根本看不出来...

如此一来我们就可以实现无缝切换的轮播图了

改进方向

我们实现了轮播图的基本功能,但是其通用性依然存在缺陷:

  1. 提示点的自定义: 我的实现是一个小点,而 antd 是用的条,这个地方完全可以将 dom 结构的决定权交给开发者.

  1. 方向的自定义: 本轮播图只有水平方向的实现,其实也可以有纵向轮播

  1. 多张轮播:除了单张轮播也可以多张轮播

以上都是可以对轮播图进行拓展的方向,相关的还有性能优化方面

我们的具体代码中有一个相关实现,我们的轮播图其实是有自动轮播功能的,但是很多时候页面并不在用户的可视页面中,我们可以根据是否页面被隐藏来取消定时器终止自动播放.

github项目地址

以上 demo 仅供参考,实际项目开发中最好还是使用成熟的开源组件,要有造轮子的能力和不造轮子的觉悟


公众号

想要实时关注笔者最新的文章和最新的文档更新请关注公众号程序员面试官,后续的文章会优先在公众号更新.

简历模板: 关注公众号回复「模板」获取

《前端面试手册》: 配套于本指南的突击手册,关注公众号回复「fed」获取

2019-08-12-03-18-41