前言
Material Design 推出已有接近4年,大家对“触摸涟漪”(Ripple)应该不陌生,简单来说就是一个水波纹效果(见下图)。前段时间接触了 material-ui 这个库,看了下 Ripple 的源码,觉得并不是一个非常好的实现,所以决定自己写一个 React 组件—— React Touch Ripple。现已开源到 Github,以及相应的 Demo。
@H_301_6@
组件拆分
我们把组件拆分为两个组件:RippleWrapper
和 Ripple
。
Ripple
就是一个圆形,涟漪本身,它会接受 rippleX
, rippleY
这样的坐标在相应位置渲染,以及 rippleSize
决定其大小。
RippleWrapper
是所有 Ripple
的容器,它内部会维护一个 state: { rippleArray: [] }
。
所有的事件监听器也会绑定在 RippleWrapper
上,每次新增一个 Ripple
就将其 push 进 rippleArray
中,相应地一个 Ripple 消失时就移除 rippleArray
的第一个元素。
@H_301_6@
Ripple
Ripple 这个组件的实现比较简单,它是一个纯函数。首先根据 Material Design 的规范,简述下动画渲染过程:
- enter 阶段:ripple 逐渐扩大(
transform: scale(0)
到transform: scale(1)
),同时透明度逐渐增加(opacity: 0
到opacity: 0.3
)。 - exit 阶段: ripple 消失,这里就不再改变
scale
,直接设置opacity: 0
。
class Ripple extends React.Component { state = { rippleEntering: false,wrapperExiting: false,}; handleEnter = () => { this.setState({ rippleEntering: true,}); } handleExit = () => { this.setState({ wrapperExiting: true,}); } render () { const { className,rippleX,rippleY,rippleSize,color,timeout,...other } = this.props; const { wrapperExiting,rippleEntering } = this.state; return ( <Transition onEnter={this.handleEnter} onExit={this.handleExit} timeout={timeout} {...other} > <span className={wrapperExiting ? 'rtr-ripple-wrapper-exiting' : ''}> <span className={rippleEntering ? 'rtr-ripple-entering' : ''} style={{ width: rippleSize,height: rippleSize,top: rippleY - (rippleSize / 2),left: rippleX - (rippleSize / 2),backgroundColor: color,}} /> </span> </Transition> ); } }
注意这两个 class:rtr-ripple-entering
,rtr-ripple-wrapper-exiting
对应这两个动画的样式。
.rtr-ripple-wrapper-exiting { opacity: 0; animation: rtr-ripple-exit 500ms cubic-bezier(0.4,0.2,1); } .rtr-ripple-entering { opacity: 0.3; transform: scale(1); animation: rtr-ripple-enter 500ms cubic-bezier(0.4,1) } @keyframes rtr-ripple-enter { 0% { transform: scale(0); } 100% { transform: scale(1); } } @keyframes rtr-ripple-exit { 0% { opacity: 1; } 100% { opacity: 0; } }
rippleX
,rippleY
,rippleSize
这些 props,直接设置 style 即可。
至于这些值是如何计算的,我们接下来看 RippleWrapper 的实现。
RippleWrapper
这个组件要做的事情比较多,我们分步来实现
事件处理
首先看 event handler 的部分。
class RippleWrapper extends React.Component { handleMouseDown = (e) => { this.start(e); } handleMouseUp = (e) => { this.stop(e); } handleMouseLeave = (e) => { this.stop(e); } handleTouchStart = (e) => { this.start(e); } handleTouchEnd = (e) => { this.stop(e); } handleTouchMove = (e) => { this.stop(e); } render () { <TransitionGroup component="span" enter exit onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseLeave={this.handleMouseLeave} onTouchStart={this.handleTouchStart} onTouchEnd={this.handleTouchEnd} onTouchMove={this.handleTouchMove} > {this.state.rippleArray} </TransitionGroup> } }
这里的 event handler 分为两部分。对于 mousedown
,touchstart
这两个事件,就意味着需要创建一个新的 Ripple,当 mouseup
,mouseleave
,touchend
,touchmove
这些事件触发时,就意味着这个 Ripple 该被移除了。
注意这里有一个“巨坑”,那就是快速点击时,onclick
事件并不会被触发。(见下图,只输出了 "mousedown"
,而没有 "onclick"
)
@H_301_6@
我们知道,Ripple 的主要用处在于 button
组件,虽然我们并不处理 click 事件,但使用者绑定的 onClick
事件依赖于它的冒泡,如果这里不触发 click 的话用户就无法处理 button
上的点击事件了。这个 bug 的产生原因直到我翻到 w3 working draft 才搞清楚。
@H_301_6@
注意这句话
The click event MAY be preceded by the mousedown and mouseup events on the same element
也就是说,mousedown 和 mouseup 需要发生在同一节点上(不包括文本节点),click 事件才会被触发。所以,当我们快速点击时,mousedown 会发生在“上一个” Ripple 上。当 mouseup 发生时,那个 Ripple 已经被移除了,它会发生在“当前”的 Ripple 上,于是 click 事件没有触发。
弄清了原因后,解决方法非常简单。我们其实不需要 Ripple
组件响应这些事件,只需要加一行 css:pointer-events: none
即可。这样一来 mousedown,mouseup 这些事件都会发生在 RippleWrapper
组件上,问题解决。
start
和 stop
start 这个函数负责计算事件发生的坐标,ripple 的大小等信息。注意在计算坐标时,我们需要的是“相对”坐标,相对 RippleWrapper
这个组件来的。而 e.clientX,e.clientY 获得的坐标是相对整个页面的。所以我们需要获得 RippleWrapper
相对整个页面的坐标(通过 getBoundingClientRect
),然后二者相减。获取元素位置的相关操作,可以参见用Javascript获取页面元素的位置 - 阮一峰的网络日志。
start (e) { const { center,timeout } = this.props; const element = ReactDOM.findDOMNode(this); const rect = element ? element.getBoundingClientRect() : { left: 0,right: 0,width: 0,height: 0,}; let rippleX,rippleSize; // 计算坐标 if ( center || (e.clientX === 0 && e.clientY === 0) || (!e.clientX && !e.touches) ) { rippleX = Math.round(rect.width / 2); rippleY = Math.round(rect.height / 2); } else { const clientX = e.clientX ? e.clientX : e.touches[0].clientX; const clientY = e.clientY ? e.clientY : e.touches[0].clientY; rippleX = Math.round(clientX - rect.left); rippleY = Math.round(clientY - rect.top); } // 计算大小 if (center) { rippleSize = Math.sqrt((2 * Math.pow(rect.width,2) + Math.pow(rect.height,2)) / 3); } else { const sizeX = Math.max(Math.abs((element ? element.clientWidth : 0) - rippleX),rippleX) * 2 + 2; const sizeY = Math.max(Math.abs((element ? element.clientHeight : 0) - rippleY),rippleY) * 2 + 2; rippleSize = Math.sqrt(Math.pow(sizeX,2) + Math.pow(sizeY,2)); } this.createRipple({ rippleX,timeout }); }
关于 stop
,没啥可说的,移除 rippleArray 的第一个元素即可。
stop (e) { const { rippleArray } = this.state; if (rippleArray && rippleArray.length) { this.setState({ rippleArray: rippleArray.slice(1),}); } }
createRipple
这个函数即创建 Ripple 使用的。start 函数最后一步使用计算出来的各项参数调用它。createRipple 就会构建一个 Ripple
,然后将其放入 rippleArray
中。
注意这个 nextKey
,这是 React 要求的,数组中每个元素都要有一个不同的 key
,以便在调度过程中提高效率
createRipple (params) { const { rippleX,timeout } = params; let rippleArray = this.state.rippleArray; rippleArray = [ ...rippleArray,<Ripple timeout={timeout} color={this.props.color} key={this.state.nextKey} rippleX={rippleX} rippleY={rippleY} rippleSize={rippleSize} /> ]; this.setState({ rippleArray: rippleArray,nextKey: this.state.nextKey + 1,}); }
其他
RippleWrapper 这个组件的核心功能基本讲完了,还有一些其他需要优化的点:
- 移动端 touch 事件的触发非常快,有时 Ripple 还没有创建出来就被 stop 了,所以需要给 touch 事件创建的 Ripple 一个延时。
- touchstart 的同时会触发 mousedown 事件,于是在移动端一次点击会“尴尬”地创建两个 Ripple。这里需要设置一个 flag,标记是否需要忽略 mousedown 的触发。
这些细节就不展开讲解了,感兴趣的读者可以参见源码。
最后
总结了以上功能我实现了 react-touch-ripple 这个库,同时引入了单元测试,flowtype 等特性,提供了一个比较简洁的 API,有此需求的读者可以直接使用。