今天来实现一下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: `${radius}px`,
height: `${radius}px`,
top: `${this.props.top}px`,
left: `${this.props.left}px`,
transitionDuration: `${this.props.duration}s`,
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下载到