Material Design 风格点击水波纹实现

2,577 阅读3分钟

先来看下效果:

代码地址:ripple

canvas绘制和css3绘制我都实现过,最终还是css效果、性能更佳。

思路

这个其实十分简单,首先分两部分去理解:

  1. css动画:水波纹的效果是 (透明度1+缩放至0) -> (透明度0+缩放至1),可以结合用@keyframes来实现。而波纹的大小交给js动态设置,再把效果写成指定的calss,最后js动态输出该节点并设置对应class即可;
  2. js逻辑部分:首先生成一个节点,然后根据点击的目标节点大小来设置波纹的尺寸,也就是根据目标节点的宽高取一个最大值作为波纹的大小,这里波纹永远都是正方形的。最后获取鼠标摁下的坐标位置,设置对应波纹css-class并输出该节点即可。至于什么时候销毁该节点,可以利用node.addEventListener("animationend", fn)来监听处理。

css 部分

[ripple] {
    position: relative;
    overflow: hidden;
}
[ripple] .ripple {
    position: absolute;
    border-radius: 100%;
    transform: scale(0);
    pointer-events: none;
    animation: ripple .4s ease-out;
}
@keyframes ripple {
    to {
        transform: scale(2);
        opacity: 0;
    }
}

js 部分

这里有个细节,就是水波纹效果结束之后被移除的节点我将他们放到一个数组里面,下次使用的时候直接从数组里面读取,之前做游戏的时候使用的一种节能模式,在网页上还没验证过,不知道有没有真正节省到性能的开销...没有的话直接删除该逻辑操作,可以尽可能的减少代码量。

/**
 * 水波纹节点对象池
 * @type {Array<HTMLElement>}
 */
const ripplePool = [];

/**
 * 点击水波纹
 * @param {TouchEvent | MouseEvent} event 点击事件
 * @param {HTMLElement} target 点击目标
 */
function ripple(event, target) {
    /**
     * 水波纹动画节点
     * @type {HTMLElement}
     */
    let node;

    // 从对象池里面拿取节点
    if (ripplePool.length > 1) {
        node = ripplePool.shift();
    } else {
        node = document.createElement("div");
        node.className = "ripple";
    }

    /** 点击目标矩阵尺寸 */
    const rect = target.getBoundingClientRect();
    // 当前自定义颜色值,如果有绑定值则获取,没有默认就是白色透明
    const color = target.getAttribute("ripple") || "rgba(255, 255, 255, .45)";
    /** 波纹大小 */
    let size = Math.max(rect.width, rect.height);
    // 设置最大范围
    if (size > 200) size = 200;
    // 设置大小
    node.style.height = node.style.width = size + "px";
    // 设置波纹颜色值
    node.style.backgroundColor = color;
    // 这里必须输出节点后再设置位置,不然会有问题
    target.appendChild(node);

    const y = event.touches ? event.touches[0].clientY : event.clientY;
    const x = event.touches ? event.touches[0].clientX : event.clientX;
    const top = y - rect.top - (node.offsetHeight / 2);
    const left = x - rect.left - (node.offsetWidth / 2);
    // console.log(top, left);
    node.style.top = top + "px";
    node.style.left = left + "px";

    function end() {
        node.removeEventListener("animationend", end);
        // console.log("动画结束", node);
        target.removeChild(node);
        ripplePool.push(node);
    }
    node.addEventListener("animationend", end);
}

使用方式

传统网页模式:

<button class="button" ripple>BUTTON-1</button>
<button class="button" ripple="rgba(0,0,0,0.1)">BUTTON-2 设置波纹颜色</button>

这里我使用事件代理去完成方法操作,因为节点有可能是动态生成的。

const mobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|OperaMini/i.test(navigator.userAgent);

/** 添加事件类型 */
const eventType = mobile ? "touchstart" : "mousedown";

document.body.addEventListener(eventType, function (e) {
    /** 事件类型 */
    const event = e || window.event || arguments.callee.caller.arguments[0];
    /** 循环的次数 */
    let loopCount = 3; // 这里的 3 次是布局的子节点层数,可根据布局层数增加减少
    /** 
     * 定义目标变量 
     * @type {HTMLElement} 
     */
    let target = event.target;
    // 循环 3 次由里向外查找目标节点
    while (loopCount > 0 && target && target != document.body) {
        loopCount--;
        if (target.hasAttribute("ripple")) {
            ripple(event, target);
            break;
        }
        target = target.parentNode;
    }
});

注意:用事件代理有个瑕疵,就是点击事件没有直接绑定在节点上灵敏,也就偶尔会出现点击没有触发对应事件的情况,页面节点较多,层级嵌套过深的时候尤为明显,所以根据实际情况来使用。

在传统模式下,也可以这样绑定事件,前提ripple方法必须为全局模式。

<button class="button" ripple onmousedown="ripple(event, this)">BUTTON-1</button>

vue 项目使用自定义指令模式使用

<button class="button" v-ripple>BUTTON-1</button>
Vue.directive("ripple", {
    inserted(el, binding) {
        el.setAttribute("ripple", binding.value || "");
        el.addEventListener(eventType, function (e) {
            ripple(e, el);
        });
    }
})

完工!