gmfe/Think

FlipNumber 组件

h11g opened this issue · 2 comments

h11g commented

背景

在需要显示下载量,用户数量,销售金额等场景,加上合适的显示效果会更有视觉表现力

两种方案

  1. 数字渐变

count-up.gif

效果如上图所示,这种方式的实现思路较为简单,只要计算出一定时长 duration 内连续递进接近目标的数字,然后不断改变现实的数字即可,首先想到的也是这种实现方式

  1. 数字滚动

另一种方案则更加复杂一点,在需要改变显示的数字的时候不是简单的直接改变,而是加入滚动的效果,效果图如下:

flip-number.gif

经过对比,最终选择实现第二种方案,因为感觉第二种更好看点,也更有挑战。

实现思路

要实现数字滚动的效果,不能直接通过改变一整个数字来达到,因为滚动的时候数字的每一位都是独立的在滚动,然而滚动过程中每一位互相也都是有关联的。

数码滚轮

先不管数字之间的关联情况,首先思考如何实现去实现滚动的数字。用过行李箱密码锁的应该都知道,我们要实现的滚动效果跟那个是差不多的,所以可以仿照行李箱密码锁的样子来实现类似的滚动效果。先画一个数码滚轮,后面根据数字的位数增加滚轮即可,这里的数码滚轮我们可以使用一条竖直方向的『数码轴』来模仿,代码如下:

  renderDigitAxis = () => {
    const numberArray = [...Array(10).keys()]
    ...
      <div
        style={{transform: `translateY(${heightList[index]}px)`}}
        ref={(rel) => { this[`gm-flip-number-digit${index}`] = rel }}
        className='gm-inline-block gm-position-relative'
        key={`digitAxis${index}`}
      >
        {
          _.map(numberArray, (i, d) => (
            <div key={`digitChild${d}`}>{i}</div>
          ))
        }
      </div>
    ...
  }

然后在外层包裹一个只能显示一个数码高度的父容器并且设置 overflow: hidden,通过改变每一个数码轴的 translateY 属性就可以达到模拟数码滚轮的效果了。先贴一张效果图,为了看得更清晰,去掉了前面说的 overflow: hidden 属性

demo.gif

视图准备(数据准备)

在实现自动滚动之前,还需要考虑一些别的东西,比如显示的数字需要小数点咋办,是否需要给数字添加万位分隔符,另外我们还要知道最终要显示什么数字,从 0 开始滚动,还是从某个数字开始滚动等。所以我们必须先确定以上因素,然后绘制出一个静态的组件视图。

初始化数据

给组件设置 to(目标数字), from(初始数字),decimal(小数点位数),useGroup(万位分隔符) 四个 props,让组件的调用方决定该组件的显示形式,然后根据 props 做一些数据的准备,代码如下:

  doInitData = (props) => {
    const {from, to, decimal, useGroup} = props
    
    // 小数点 + useGroup
    this.fromStr = formatNum(from, decimal, useGroup)
    this.toStr = formatNum(to, decimal, useGroup)
    
    // 格式化滚动数字数组
    const totalLen = getNumLength(this.fromStr, this.toStr)
    this.toRawArr = getRawArray(this.toStr, totalLen) // to 的字符串数组
    this.toNumArr = filterForNum(this.toRawArr.rawList).map(Number) // to 的去掉',' '.'后的数字数组
    this.digitLen = this.toNumArr.length
    this.fromRawArr = getRawArray(this.fromStr, totalLen)
    this.fromNumArr = filterForNum(this.fromRawArr.rawList).map(Number)
  }

在这段代码中,formatNum 函数根据 props 得到 tofrom 的字符串形式,比如当 decimal2useGrouptrue 时,1234 会返回 1,234.00。然后根据 tofrom 的字符串获取到相应的字符串数组 ['1', ',', '2', '3', '4', '.', '0', '0']['1', '2', '3', '4', '0', '0'][{symbol: ',', position: 1}, {symbol: '.', position: 5}]

绘制视图

再来看看组件的 render 函数:

  renderDigitAxis = () => {
    const {heightList} = this.state
    const numberArray = [...Array(10).keys()]
    // 数码轴数组
    const digitAxis = _.map(this.toNumArr, (item, index) => (
      <div style={{transform: `translateY(${heightList[index]}px)`}}>
        {
          _.map(numberArray, (i, d) => (
            <div>{i}</div>
          ))
        }
      </div>
    ))

    // 在数码轴数组中相应位置插入符号轴
    _.forEach(this.toRawArr.symbolList, (item, index) => {
      const symbolAxis = (
        <div className='gm-inline-block'}>
          {
            _.map(numberArray, (i, d) => (
              <div>{item.symbol}</div>
            ))
          }
        </div>
      )
      digitAxis.splice(item.position, 0, symbolAxis)
    })

    return digitAxis
  }
  
  render () {
    return <div>
      {this.renderDigitAxis()}
    </div>
  }

render 函数中根据 tofrom 的字符数组绘制了相应数量的滚动轴,效果图如下:

init.png

滚动实现

以从 from = 1234to = 5678 为例,分为两个步骤实现滚动

显示初始数字

componentDidMount 中获取每个数字的高度,然后计算出每个数码轴需要滚动的距离来显示初始的数字 from,代码如下:

  componentDidMount () {
    this.height = this['gm-flip-number-digit0'].clientHeight / 10
    this.doInitView(this.props)
  }
  
  doInitView = (props) => {
    const heightList = []
    _.forEach(this.fromNumArr, (item) => {
      const height = -parseInt(item, 10) * this.height
      heightList.push(height)
    })
    this.setState({
      heightList: heightList
    })
    ...
  }

通过循环计算出每个数码轴应该偏移的距离,保存在 heightList 中,然后 setState 更新数码轴的位置,就可以看到组件在显示 from 的数字。

规律滚动

最后一步,就是如何让组件从 from 有规律滚动到 to 的问题了,接下来通过公式来计算每一时刻每条数码轴滚动的距离,

number = (percent * alter + from) % 10
  • number: 结果是 0 ~ 10 之间的任何数字,表示此刻显示的数字位置,如 number = 1, 则显示 1, number = 1.5 则显示 1 和 2 中间的位置
  • percent: 动画进行的时间百分比,即 timeConsuming / duration
  • alter: 这里可以理解为步长,表示某一条数码轴上 tofrom 的差距,即 toNumArr[i] - fromNumArr[i]
  • from: 该数码轴上对应的 from 数码,即 fromNumArr[i]

由此写出一个控制滚动开始的方法

  doInitView = (props) => {
    const {delay, duration} = props
    
    ...
    
    delay ? this.timeoutID = setTimeout(() => this.flipTo(duration), delay) : this.flipTo(duration)
  }

  getDigitPosition = ({from, percent, alter}) => {
    const expectNum = (percent * alter + from) % 10
    return -expectNum * this.height
  }

  flipTo = (duration) => {
    const {easeFn, individually} = this.props
    
    const draw = percent => {
      let temp = 0
      const heightList = []
      _.forEach(this.toNumArr, (to, index) => {
        const alter = to - this.fromNumArr[index]
        temp += alter
        const height = this.getDigitPosition({
          from: this.fromNumArr[index],
          percent: easeFn(percent),
          alter: individually ? temp : alter
        })
        heightList.push(height)
        temp *= 10
      })
      this.setState({
        heightList
      })
    }
    
    const startTime = window.performance.now()
    const tick = now => {
      let timeConsuming = now - startTime
      draw(timeConsuming / duration)
      if (timeConsuming < duration) this.requestId = window.requestAnimationFrame(tick)
      else {
        draw(1)
      }
    }
    
    this.requestId = window.requestAnimationFrame(tick)
  }

其中用于包装 percent 参数的方法 easeFn 可以起到控制动画加速度的作用

  /**
   * 缓动函数
   * @see https://github.com/danro/easing-js/blob/4f5e7edbde7f7200a1baf08e357377896c0d207e/easing.js#L39-L42
   */
  easeFn: pos => (pos /= 0.5) < 1
    ? 0.5 * Math.pow(pos, 3)
    : 0.5 * (Math.pow((pos - 2), 3) + 2)

总结

开始的时候代码跟着思路来写的,就算出需要位移的高度 height 之后直接操作 DOM 改变数码轴的位置了,代码大概如下:

this.dom.style.transform = `translateY(${height}px)`

就代码跟着思路走了,没有考虑到 react 不操作 DOM 的特点,有时候思路和想要实现的效果是一回事,代码实现可能是另一个方式。

另外,感觉组件还有一个不好的问题就是,为了响应调用方通过改变 setState 来改变传进来的 to,在 componentWillReceiveProps 方法中添加了如下代码:

  componentWillReceiveProps (nextProps) {
    if (nextProps.to !== this.props.to) {
      window.cancelAnimationFrame(this.requestId)
      clearTimeout(this.timeoutID)
      this.doInitData(nextProps)
      this.doInitView(nextProps)
    }
  }

接受到不同的 to 的时候重新计算了需要的数据并且重新滚动,因为这些数据只需要计算一次,如果放在 render 函数中,那么在滚动的时候就会不断的重复计算,浪费资源,但是 React 并不推荐使用

Note:

These methods are considered legacy and you should avoid them in new code:

  • UNSAFE_componentWillUpdate()
  • UNSAFE_componentWillReceiveProps()

同时 React 也给了建议:unsafe_componentwillreceiveprops

1 在性能上有什么考虑么?
2 react思维化上有什么收获?

h11g commented

@liyatang
1.性能上主要考虑了两点,一是准备数据时的计算,考虑到 render 执行数量较多且频率高,虽然计算量不是很大,还是把一些只需要计算一次的步骤放到了construtor;二是实现滚动的时候需要高频率的改变translateY,没有采取没计算出一个数字轴的高度就马上setState,而是保存在一个列表中,计算出所有数字轴的需要滚动的高度后才setState
2.react 上的收获是使用数据驱动视图,不要直接操作 dom 改变 view,而应该通过计算改变相关的数据来让 react 决定是否更新 view