水平无限循环弹幕的实现

3,146 阅读4分钟

前言

在项目实践中应该有很多场景会用到弹幕,那么如何实现一个完美版本的弹幕呢?接下来我们原理加代码带你实现一个完整的弹幕组件(react版本)

无限循环的水平弹幕实现原理

针对实现原理,这里我画了一张原理图,大家可以看一下:

水平弹幕的实现有两种情况:

1、当弹幕的个数加起来的宽度不足以覆盖屏幕的可视化区域

2、当弹幕的个数加起来的宽度超过屏幕的可视化区域

针对以上两种情况我们有不同的展示效果,如下链接的展示效果:

针对第一种情况,实现原理很简单,当从初始化位置开始滚动的时候,计算滚动的距离,当滚动结束后,立马让其回到初始化位置。

第二种情况稍微复杂一些,我们需要利用人眼的视觉暂留效果,实现弹幕的偷梁换柱,具体怎么实现呢?

  1. 初始化位置在屏幕最右侧,也就是隐藏在屏幕外面,这一点和上一种情况一致

  2. 我们需要使用一个计算好的速度做一次动画滑到屏幕的最左侧,也就是后面循环往复的动画的初始化位置,这个初始化位置和第一点的初始化位置不是同一个。 2.1. 计算这个速度很简单,只需要知道我们做完全部动画的时间以及弹幕的总长度,得到的便是平均速度,之后再乘以屏幕的可视化区域宽度

  3. 需要计算好我们在原有弹幕个数的基础上需要补充多少个弹幕才能超过屏幕可视化区域,做这个步骤是因为,只有补充这些弹幕,才能保证补充的弹幕的第一个滑到最左侧的时候整个弹幕整体瞬间回到初始化位置的时候,不会让用户看出端倪,也就是没有顿挫感。

  4. 定好keyframe的具体参数即可开始做动画。

实现的代码

实现的代码是一个组件,这个组件有兴趣的童鞋可以将其丰富化,增加更多的参数,支持各种方向的循环滚动。

class InfiniteScroll extends React.Component {
  componentDidMount() {
    const { animationName, scrollDirection } = this.props
    setTimeout(() => {
      if (this.scrollInstance) {
        const appendElementWidth = []
        const appendElement = []
        const visibleWidth = this.scrollInstance.clientWidth
        // 滚动的初始位置从视口的最右边开始,后面支持更多方向
        const initPosition = visibleWidth
        let scrollContainerWidth = 0
        let isCoverViewPort = false
        // 遍历滚动的所有元素
        for (let i = 0; i < this.scrollInstance.children.length; i += 1) {
          const style = this.scrollInstance.children[i].currentStyle || window.getComputedStyle(this.scrollInstance.children[i])
          // width 已经包含了border和padding,如果box-sizing变化了呢?
          const width = this.scrollInstance.children[i].offsetWidth// or use style.width
          const margin = parseFloat(style.marginLeft) + parseFloat(style.marginRight)

          const clientWidth = (width + margin)
          scrollContainerWidth += clientWidth
          // 保存需要追加到原有滚动元素的列表后面
          if (scrollContainerWidth < visibleWidth * 2 && !isCoverViewPort) {
            appendElementWidth.push(clientWidth)
            appendElement.push(this.scrollInstance.children[i].cloneNode(true))

            if (scrollContainerWidth >= visibleWidth && !isCoverViewPort) {
              isCoverViewPort = true
            }
          }
        }
        // 该参数记录是否弹幕的宽度超过了屏幕可视化区域的宽度
        const isScrollWidthLargeViewPort = scrollContainerWidth > visibleWidth

        // const styleSheet = document.styleSheets[0]

        // 注意这里的动画初始化位置两种情况是不一样的,在之前的步骤上有说过的(垂直方向的没实现,可以忽略掉~)
        const keyframes = `
        @keyframes ${animationName}{
          from{
            transform: ${scrollDirection === 'horizon' ? `translateX(${isScrollWidthLargeViewPort ? 0 : initPosition}px)` : 'translateY(0px)'};
          }
          to {
            transform: ${scrollDirection === 'horizon' ? `translateX(-${scrollContainerWidth}px)` : `translateX(-${scrollContainerWidth}px)`};
          }
        }
        @-webkit-keyframes ${animationName}{
          from{
            -webkit-transform: ${scrollDirection === 'horizon' ? `translateX(${isScrollWidthLargeViewPort ? 0 : initPosition}px)` : 'translateY(0px)'};
          }
          to {
            -webkit-transform: ${scrollDirection === 'horizon' ? `translateX(-${scrollContainerWidth}px)` : `translateX(-${scrollContainerWidth}px)`};
          }
        }
        `
        const style = document.createElement('style')
        const head = document.head || document.getElementsByTagName('head')[0]

        style.type = 'text/css'
        const textNode = document.createTextNode(keyframes);
        style.appendChild(textNode);
        head.appendChild(style)
        // 如果css是external的话会报错:Uncaught DOMException: Failed to read the 'cssRules' property from 'CSSStyleSheet': Cannot access rules
        // styleSheet.insertRule(keyframes, styleSheet.cssRules.length);
        const previousWidth = scrollContainerWidth
        // 这个计算之后scrollContainerWidth就会包含那些补充了的弹幕的宽度,所以需要保留一个原始值,供后面的过渡动画使用
        if (isScrollWidthLargeViewPort) {
          appendElement.map(node => this.scrollInstance.appendChild(node))
          appendElementWidth.map(it => scrollContainerWidth += it)
        }

        // TODO: 动画的速度以后需要使用props
        // 因为初始化位置在视口外,但是我们动画的初始位置都是在0px上,所以就会有一个时差出现,
        // 因为在animation生效之前需要有一个过渡动画,二者的时间是相等的
        const delay = (this.scrollInstance.children.length * 3 * visibleWidth) / previousWidth
        const styleText = isScrollWidthLargeViewPort ? `
        width: ${scrollContainerWidth}px;
        transform: translateX(0px);
        -webkit-transform: translateX(0px);
        transition: ${delay}s linear;
        -webkit-transition: ${delay}s linear;
        animation: ${animationName} ${this.scrollInstance.children.length * 3}s linear ${delay}s infinite;
        -webkit-animation: ${animationName} ${this.scrollInstance.children.length * 3}s linear ${delay}s infinite;
      ` : `
      width: ${scrollContainerWidth}px;
      animation: ${animationName} ${this.scrollInstance.children.length * 3}s linear infinite;
      -webkit-animation: ${animationName} ${this.scrollInstance.children.length * 3}s linear infinite;
    `
        this.scrollInstance.style.cssText = styleText
      }
    }, 500)
  }
  render() {
    const { scrollContent, scrollItemClass, scrollClass, scrollDirection } = this.props

    const scrollClasses = scrollDirection === 'vertical' ? `scroll-list vertical ${scrollClass}` : `scroll-list horizon ${scrollClass}`

    return (
      <div className={scrollClasses} ref={ref => this.scrollInstance = ref}>
        {
          scrollContent.map((content, index) => (<div key={index} className={scrollItemClass}>{content}</div>))
        }
      </div>
    )
  }
}

对应的CSS文件如下:

.scroll-list{
  display: flex;
  &.horizon {
    flex-direction: row;
  }
  &.vertical{
    flex-direction: column;
  }
}

完整的应用参考:jsFiddle

至此完整版的水平循环弹幕实现完毕,有问题的欢迎留言~~