Material Design 推出已有接近4年,你們對「觸摸漣漪」(Ripple)應該不陌生,簡單來講就是一個水波紋效果(見下圖)。前段時間接觸了 material-ui 這個庫,看了下 Ripple 的源碼,以爲並非一個很是好的實現,因此決定本身寫一個 React 組件—— React Touch Ripple。現已開源到 Github,以及相應的 Demo。javascript
咱們把組件拆分爲兩個組件:RippleWrapper
和 Ripple
。css
Ripple
就是一個圓形,漣漪自己,它會接受 rippleX
, rippleY
這樣的座標在相應位置渲染,以及 rippleSize
決定其大小。html
RippleWrapper
是全部 Ripple
的容器,它內部會維護一個 state: { rippleArray: [] }
。java
全部的事件監聽器也會綁定在 RippleWrapper
上,每次新增一個 Ripple
就將其 push 進 rippleArray
中,相應地一個 Ripple 消失時就移除 rippleArray
的第一個元素。react
Ripple 這個組件的實現比較簡單,它是一個純函數。首先根據 Material Design 的規範,簡述下動畫渲染過程:git
transform: scale(0)
到 transform: scale(1)
),同時透明度逐漸增長(opacity: 0
到 opacity: 0.3
)。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
對應這兩個動畫的樣式。github
.rtr-ripple-wrapper-exiting { opacity: 0; animation: rtr-ripple-exit 500ms cubic-bezier(0.4, 0, 0.2, 1); } .rtr-ripple-entering { opacity: 0.3; transform: scale(1); animation: rtr-ripple-enter 500ms cubic-bezier(0.4, 0, 0.2, 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 便可。segmentfault
至於這些值是如何計算的,咱們接下來看 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"
)
咱們知道,Ripple 的主要用處在於 button
組件,雖然咱們並不處理 click 事件,但使用者綁定的 onClick
事件依賴於它的冒泡,若是這裏不觸發 click 的話用戶就沒法處理 button
上的點擊事件了。這個 bug 的產生緣由直到我翻到 w3 working draft 才搞清楚。
注意這句話
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, rippleY, 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, rippleY, rippleSize, 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, rippleY, rippleSize, 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 這個組件的核心功能基本講完了,還有一些其餘須要優化的點:
這些細節就不展開講解了,感興趣的讀者能夠參見源碼。
總結了以上功能我實現了 react-touch-ripple 這個庫,同時引入了單元測試,flowtype 等特性,提供了一個比較簡潔的 API,有此需求的讀者能夠直接使用。