【教程】React 實現 Material Design 中漣漪(Ripple)效果

前言

Material Design 推出已有接近4年,你們對「觸摸漣漪」(Ripple)應該不陌生,簡單來講就是一個水波紋效果(見下圖)。前段時間接觸了 material-ui 這個庫,看了下 Ripple 的源碼,以爲並非一個很是好的實現,因此決定本身寫一個 React 組件—— React Touch Ripple。現已開源到 Github,以及相應的 Demojavascript

組件拆分

咱們把組件拆分爲兩個組件:RippleWrapperRipplecss

Ripple 就是一個圓形,漣漪自己,它會接受 rippleXrippleY 這樣的座標在相應位置渲染,以及 rippleSize 決定其大小。html

RippleWrapper 是全部 Ripple 的容器,它內部會維護一個 state: { rippleArray: [] }java

全部的事件監聽器也會綁定在 RippleWrapper 上,每次新增一個 Ripple 就將其 push 進 rippleArray 中,相應地一個 Ripple 消失時就移除 rippleArray 的第一個元素。react

Ripple 和 RippleWrapper 的關係

Ripple

Ripple 這個組件的實現比較簡單,它是一個純函數。首先根據 Material Design 的規範,簡述下動畫渲染過程:git

  • enter 階段:ripple 逐漸擴大(transform: scale(0)transform: scale(1)),同時透明度逐漸增長(opacity: 0opacity: 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-enteringrtr-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; }
}

rippleXrippleYrippleSize 這些 props,直接設置 style 便可。segmentfault

至於這些值是如何計算的,咱們接下來看 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 分爲兩部分。對於 mousedowntouchstart 這兩個事件,就意味着須要建立一個新的 Ripple,當 mouseupmouseleavetouchendtouchmove 這些事件觸發時,就意味着這個 Ripple 該被移除了。

注意這裏有一個「巨坑」,那就是快速點擊時,onclick 事件並不會被觸發。(見下圖,只輸出了 "mousedown",而沒有 "onclick"

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 組件上,問題解決。

startstop

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 這個組件的核心功能基本講完了,還有一些其餘須要優化的點:

  • 移動端 touch 事件的觸發很是快,有時 Ripple 尚未建立出來就被 stop 了,因此須要給 touch 事件建立的 Ripple 一個延時。
  • touchstart 的同時會觸發 mousedown 事件,因而在移動端一次點擊會「尷尬」地建立兩個 Ripple。這裏須要設置一個 flag,標記是否須要忽略 mousedown 的觸發。

這些細節就不展開講解了,感興趣的讀者能夠參見源碼

最後

總結了以上功能我實現了 react-touch-ripple 這個庫,同時引入了單元測試,flowtype 等特性,提供了一個比較簡潔的 API,有此需求的讀者能夠直接使用。

附上源碼:https://github.com/froyog/react-touch-ripple

相關文章
相關標籤/搜索