Material Design Ripple Button

Posted wlbreath

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Material Design Ripple Button相关的知识,希望对你有一定的参考价值。

今天来实现一下android material design 中ripple波纹效果,最终效果如下:

因为我关注的是实现的原理,所以这里没有考虑兼容性,所以对于感兴趣的同学,如果下了源代码,请在chrome下运行。

另外对于代码比较刚兴趣的同学,可以在Material Design Ripple Button下载到

实现原理

原理很简单,就是在点击的位置添加一个圆形的span。开始的时候这个span的半径为0,透明度为1。然后慢慢的我们扩大圆形span的半径,减小透明度。最后等到圆形span的透明度为0的时候,我们再将添加的span移除就可以了。

代码实现

因为代码是用react 和es6实现的,对于不了解的同学可以参考下面的资料:
React
es6
babel
browserify

index.html

<html>
    <head>
        <meta charset="utf-8">

        <style type="text/css">
            * 
                margin: 0;
                padding: 0;
                box-sizing: border-box;
            

            #app 
                margin: 100px;
            
        </style>
    </head>

    <body>
        <div id="app"></div>

        <script type="text/javascript" src="./bundle.js"></script>
    </body>
</html>

第19行id为app的div是整个demo的入口元素,我们演示的那个按钮就是放在那个div中的,

第21行中我们引入了browserify 打包后的js文件(bundle.js),其中打包之前的文件包括index.js、button.js和circle-ripple.js,下面我们将会详细的介绍这几个文件

index.js

import React from "react";
import ReactDOM from "react-dom";

import Button from "./lib/button";

ReactDOM.render(
    <Button label="BUTTON"/>,
    document.getElementById("app")
);

demo的入口js文件,这里我们引入实现好的Button,然后将其放在了index.html中id为app的div中

button.js

import React from 'react';
import ReactDOM from 'react-dom';
import ReactTransitionGroup from 'react-addons-transition-group';

import CircleRipple from './circle-ripple';

const DefaultButtonStyle = 
    position: 'relative',
    display: 'inline-block',
    padding: '0 16px',
    height: '36px',
    lineHeight: '36px',
    overflow: 'hidden',
    fontSize: '14px',
    outline: 'none',
    border: 0,
    cursor: 'pointer',
    textAlign: 'center',
    background: '#ffffff',
    boxShadow: '0 0 6px 0 #C3B7B7',
;

const DefaultTransitionGroupStyle= 
    display: 'block',
    position: 'absolute',
    top: 0,
    left: 0,
    width: '100%',  
    height: '100%',
;

const DefaultLabelStyle = 
    position: 'relative',
    background: 'transparent',
;

let uuid = 0;

class Button extends React.Component 
    constructor(props)
        super(props);

        this.state = 
            ripples: []
        ;

        [
            '_handleClick',
            '_removeRipple',
            '_getCircleRipplePosition',
            '_getBtnStyle',
            '_getTransitonGroupStyle',
            '_getLabelStyle',

        ].forEach(func => 
            this[func] = this[func].bind(this);
        );
    

    _handleClick(e)
        this.props.onClick && this.props.onClick(e);
        let ripples = this.state.ripples;

        let position = this._getCircleRipplePosition(e);

        let newRipple = (
            <CircleRipple 
               key=uuid++ 
               ...position 
               onAnimationEnd= () => this._removeRipple(newRipple);   />
        );

        ripples.push(newRipple);
        this.setState(
            ripples: ripples
        );
    

    _removeRipple(ripple)
        let ripples = this.state.ripples;

        for(let i=0, len=ripples.length; i < len; ++i)
            if(ripple === ripples[i])
                ripples.splice(i, 1);
                break;
            
        

        this.setState(
            ripples: ripples
        );
    

    _getCircleRipplePosition(e)
        let el = ReactDOM.findDOMNode(this);
        let rect = el.getBoundingClientRect();

        return 
            top: e.pageY - (rect.top + window.scrollY),
            left: e.pageX - (rect.left + window.scrollX)
        ;
    

    _getBtnStyle()
        return Object.assign(, DefaultButtonStyle, this.props.style);
    

    _getTransitonGroupStyle()
        return Object.assign(, DefaultTransitionGroupStyle);
       

    _getLabelStyle()
        return Object.assign(, DefaultLabelStyle, this.props.labelStyle);
    

    render()
        let 
            className,

         = this.props;

        return (
            <button
               className=className
               style=this._getBtnStyle()
               onClick= this._handleClick >

               <ReactTransitionGroup 
                  style=this._getTransitonGroupStyle()>
                  this.state.ripples
               </ReactTransitionGroup>

               <span
                  style=this._getLabelStyle()>
                  this.props.label || ''
               </span>

            </button>
        );
    


Button.defaultProps = 
    label: "",
    style: ,
    labelStyle: ,
;

Button.propTypes = 
    label: React.PropTypes.string,
    onClick: React.PropTypes.func,
    style: React.PropTypes.object,
    labelStyle: React.PropTypes.object,
;

export default Button;

首先我们来看看render函数的实现,其实很简单就是一个button中包括了一些简单的内容而已

<button
   className=className
   style=this._getBtnStyle()
   onClick= this._handleClick >

   <ReactTransitionGroup 
      style=this._getTransitonGroupStyle()>
      this.state.ripples
   </ReactTransitionGroup>

   <span
      style=this._getLabelStyle()>
      this.props.label || ''
   </span>

</button>

我们发现button中有两个子元素,一个是ReactTransitionGroup,这个就是用来包括ripple波纹的,我们的波纹元素是保存在this.state.ripples中的,在构造函数中我们将它初始化为一个空的数组。第二个元素就是span,这个元素是用来显示label的(也就是我们想要显示在按钮中的内容)

然后我们可以看到我们给button元素添加了一个onClick事件,用_handleClick函数来响应点击事件,让我们看一下这个函数里面都做了什么。

_handleClick(e)
   this.props.onClick && this.props.onClick(e);
   let ripples = this.state.ripples;

   let position = this._getCircleRipplePosition(e);
   let newRipple = (
      <CircleRipple 
         key=uuid++ 
         ...position 
         onAnimationEnd= () => this._removeRipple(newRipple);   />
      );

   ripples.push(newRipple);
   this.setState(
      ripples: ripples
   );

也很简单,如果用户绑定了对应的点击事件,首先调用用户绑定的函数,然后我们创建一个CircleRipple,然后将其添加到this.state.ripples中。在创建CircleRipple的时候,我们给它添加了一个onAnimationEnd事件的回调函数,这个回调函数会在波纹动画效果执行完之后运行,我们这里是在波纹动画执行完以后将CircleRipple从button中删除。

circle-ripple.js

import React from "react";
import ReactDOM from "react-dom";

const DefaultStyle = 
    position: "absolute",
    width: "100%",
    height: "100%",
    top: 0,
    left: 0,
    opacity: 1,
    borderRadius: "50%",
    background: "#ABA6A6",
    transform: "translate(-50%, -50%) scale(0)",
    transitionTimingFunction: 'ease-out',
    transitionDuration: '0.5s',
    transitionProperty: 'transform, opacity',
;

class CircleRipple extends React.Component 
    constructor(props)
        super(props);

        this.state = 
            style: DefaultStyle,
        ;

        this._timeoutId;

        [
            '_startAnimation',

        ].forEach(func=>
            this[func] = this[func].bind(this);
        );
    

    componentWillAppear(callback)
        setTimeout(callback, 0);
    

    componentWillEnter(callback) 
        setTimeout(callback, 0);
    

    componentDidAppear()
        this._startAnimation();
    

    componentDidEnter()
        this._startAnimation();
    

    componentDidMount()
        if(this.props.onAnimationEnd)
            this._timeoutId = setTimeout(this.props.onAnimationEnd, this.props.duration * 1000);
        
    

    componentWillMount()
        if(this._timeoutId !== null && this._timeoutId !== void 0)
            clearTimeout(this._timeoutId);
            delete this._timeoutId;
        
    

    _startAnimation()
        const thisEl   = ReactDOM.findDOMNode(this);
        const parentEl = thisEl.parentElement || thisEl.parentNode;

        let parentStyle = window.getComputedStyle(parentEl, null);

        let radius = Math.max(parseInt(parentStyle.width), parseInt(parentStyle.height)) * 2;

        let style = Object.assign(, DefaultStyle, 
            opacity: 0,
            width: `$radiuspx`,
            height: `$radiuspx`,
            top: `$this.props.toppx`,
            left: `$this.props.leftpx`,
            transitionDuration: `$this.props.durations`,
            transform: "translate(-50%, -50%) scale(1)",
        );

        this.setState(style: style);
    

    render()       
        return (
            <span style =this.state.style></span>
        );
    


CircleRipple.defaultProps = 
    top: 0,
    left: 0,
    duration: 0.5,
    style: DefaultStyle,
;

CircleRipple.propTypes = 
    top: React.PropTypes.number,
    left: React.PropTypes.number,
    style: React.PropTypes.object,

    duration: React.PropTypes.number,
    onAnimationEnd: React.PropTypes.func
;

export default CircleRipple;

这里实现比较简单就是在circle-ripple在被添加到ReactTransitionGroup的时候,播放scale和opacity动画,scale动画时候scale动画是通过transform来实现的,主要为transform: scale(0) —> transform: scale(1)。对于opacity动画就更简单了就是从1到0的过程而已。

不过这里有几点是值得注意的地方:

第一点:circle-ripple的position是absolute,top和left都为用户点击的位置。由于top和left都是指本身元素的左上角相对于父元素的位移,可是 ,可是这个不是我们想要的,我们想要的是,我们的元素的中心点应该在用户点击的位置,这里我们是通过tansform:translate(-50%, -50%)来实现的。

第二点:因为这里我们的动画是通过css中transition来实现的,因为我们这里有两个动画同时执行的,动画结束事件的触发如果通过transitionend事件来实现的话,这里将会触发两次transitionend事件,这样处理比较麻烦,所以这里我们是通过setTimeout实现的。

以上就是整个代码的实现过程,对于写的不足的地方,请尽量提出,我也好学习。对于代码比较刚兴趣的同学,可以在Material Design Ripple Button下载到

以上是关于Material Design Ripple Button的主要内容,如果未能解决你的问题,请参考以下文章

将 Material Design Touch Ripple 应用于 ImageButton?

Android Material Design : Ripple Effect水波波纹荡漾的视觉交互设计

Android 5.X 新特性详解——Material Design 动画效果

Android Material Design-Creating Apps with Material Design(用 Material Design设计App)-(零)

bootstrap-material-design-个人总结

Material Design学习笔记