【教程】React 实现 Material Design 中涟漪(Ripple)效果

前端之家收集整理的这篇文章主要介绍了【教程】React 实现 Material Design 中涟漪(Ripple)效果前端之家小编觉得挺不错的,现在分享给大家,也给大家做个参考。

前言

Material Design 推出已有接近4年,大家对“触摸涟漪”(Ripple)应该不陌生,简单来说就是一个水波纹效果(见下图)。前段时间接触了 material-ui 这个库,看了下 Ripple 的源码,觉得并不是一个非常好的实现,所以决定自己写一个 React 组件—— React Touch Ripple。现已开源到 Github,以及相应的 Demo

@H_301_6@

组件拆分

我们把组件拆分为两个组件:RippleWrapperRipple

Ripple 就是一个圆形,涟漪本身,它会接受 rippleXrippleY 这样的坐标在相应位置渲染,以及 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: 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 对应这两个动画的样式。

.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; }
}

rippleXrippleYrippleSize 这些 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 分为两部分。对于 mousedowntouchstart 这两个事件,就意味着需要创建一个新的 Ripple,当 mouseupmouseleavetouchendtouchmove 这些事件触发时,就意味着这个 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 组件上,问题解决

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,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,有此需求的读者可以直接使用。

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

猜你在找的React相关文章