FlipNumber 组件
h11g opened this issue · 2 comments
背景
在需要显示下载量,用户数量,销售金额等场景,加上合适的显示效果会更有视觉表现力
两种方案
- 数字渐变
效果如上图所示,这种方式的实现思路较为简单,只要计算出一定时长 duration
内连续递进接近目标的数字,然后不断改变现实的数字即可,首先想到的也是这种实现方式
- 数字滚动
另一种方案则更加复杂一点,在需要改变显示的数字的时候不是简单的直接改变,而是加入滚动的效果,效果图如下:
经过对比,最终选择实现第二种方案,因为感觉第二种更好看点,也更有挑战。
实现思路
要实现数字滚动的效果,不能直接通过改变一整个数字来达到,因为滚动的时候数字的每一位都是独立的在滚动,然而滚动过程中每一位互相也都是有关联的。
数码滚轮
先不管数字之间的关联情况,首先思考如何实现去实现滚动的数字。用过行李箱密码锁的应该都知道,我们要实现的滚动效果跟那个是差不多的,所以可以仿照行李箱密码锁的样子来实现类似的滚动效果。先画一个数码滚轮,后面根据数字的位数增加滚轮即可,这里的数码滚轮我们可以使用一条竖直方向的『数码轴』来模仿,代码如下:
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
属性
视图准备(数据准备)
在实现自动滚动之前,还需要考虑一些别的东西,比如显示的数字需要小数点咋办,是否需要给数字添加万位分隔符,另外我们还要知道最终要显示什么数字,从 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
得到 to
和 from
的字符串形式,比如当 decimal
为 2
,useGroup
为 true
时,1234
会返回 1,234.00
。然后根据 to
和 from
的字符串获取到相应的字符串数组 ['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
函数中根据 to
和 from
的字符数组绘制了相应数量的滚动轴,效果图如下:
滚动实现
以从 from = 1234
和 to = 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: 这里可以理解为步长,表示某一条数码轴上
to
与from
的差距,即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思维化上有什么收获?