点击按钮时出现波纹效果的实现

3,781 阅读3分钟

前言

最近看到一个按钮组件发现在点击的时候会出现一种类似波纹涟漪的效果(见动图示例),很是好看,于是就研究了一下它的实现方式,现在分享给大家。这个分享我使用的是 Vue 来实现,如果你想移植到别的框架中也很简单,核心逻辑都是通用的,只是模板部分的语法稍微改改就行了。

按钮效果示例:

ripple.gif

一、单个波纹效果的实现

咱们先来实现单个波纹效果,以便理解具体的原理,最后再来实现一个多波纹的最终版。

从效果上看,在按钮里面的任何一个地方点击,就会在鼠标点击的位置出现一个圆形的波纹涟漪扩散开去,还有颜色从深到浅到最后再消失的一个渐变。

既然涟漪是圆形的,那么这个问题就可以转换成:在鼠标点击的位置出现一个从小到大并且颜色逐渐从深变浅的圆形。

因为波纹效果是需要放在某个容器中来使用的,所以本文就把它放到按钮中来进行讲解。

搭建框架

首先,创建一个名为:ripple-ink.vue 的文件,内容如下:

<template>
  <div class="ripple-ink">
  </div>
</template>

<script>
export default {
  name: "RippleInk",
  data() {
    return {
    };
  },
  computed: {
  },
  methods: {
  },
};
</script>

<style lang="scss" scoped>
.ripple-ink {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  border-radius: inherit;
}
</style>

把涟漪容器的大小和边框圆角设置为父级的大小,因为使用了 absolute 属性,所以在使用波纹组件的时候,父组件得加上 position: relative 才行,否则涟漪就会扩散。

在演示文件比如:App.vue 中引入波纹组件,并写一个简单的按钮形状,把 ripple-ink 放到按钮内部,就可以了,要注意按钮得加上 position: relative

<template>
  <div class="button">
    <span>提交</span>
    <ripple-ink></ripple-ink>
  </div>
</template>
<script>
import RippleInk from './components/ripple-ink.vue';
export default {
  name: "App",
  components: {
    RippleInk,
  },
}
</script>
<style >
body {
  margin: 0;
  padding: 0;
}
.button {
  color: #326de6;
  line-height: 40px;
  margin-left: 100px;
  margin-top: 100px;
  position: relative;
  width: 120px;
  height: 40px;
  border: 1px solid #ccc;
  border-radius: 4px;
  background: #e0e0e0;
  cursor: pointer;
}
</style>

加上涟漪

因为点击时才会出现涟漪,所以在组件中加上鼠标事件,需要拆分成鼠标按下和抬起的操作,再加上一个用于执行涟漪效果的子级 div,因为最后涟漪要消失,所以使用 v-show 来控制它的显示和隐藏。

因为涟漪是圆形的,初始大小是 0,并且有动画,所以再把涟漪的样式加上。

涟漪是圆形的,咋弄?用边框啊,把边框设成圆的就行了。

接下来是涟漪的背景颜色,咱们不能定死一种颜色,所以可以考虑使用从父级继承的 currentColor

初始 scale(0) 是必须的,等展示涟漪的时候就会把它设为 1,最后加上过渡的效果就可以了,代码如下:

<template>
  <div class="ripple-ink" @mousedown="handleMouseDown" @mouseup="handleMouseUp">
    <div v-show="isShow" class="ink"></div>
  </div>
</template>

<script>
export default {
  name: "RippleInk",
  data() {
    return {
      isShow: false,
    };
  },
  computed: {
  },
  methods: {
    handleMouseDown(e) {
      this.isShow = true;
    },
    handleMouseUp() {
      this.isShow = false;
    },
  },
};
</script>

<style lang="scss" scoped>
.ripple-ink {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  border-radius: inherit;
  .ink {
    width: 0;
    height: 0;
    position: absolute;
    // 把边框设为圆形,这样涟漪就是圆形的了
    border-radius: 50%;
    background-color: currentColor;
    transform: scale(0);
    transition: transform 0.6s ease-out, opacity 0.6s ease-out;
  }
}
</style>

准备工作完成了,接下来就是核心部分:点击按钮的时候,要给涟漪加上宽度,还得设置涟漪的位置。

要如何确定涟漪的尺寸大小呢?最简单的办法就是设一个尺寸超过按钮大小的圆,这样不论在按钮的哪个地方都不会出现涟漪覆盖不到的情况。因为在网页中所有 div 的基础形状都是矩形,所以我们就可以使用计算对角线的公式来算出一个基准值,只要涟漪的大小超过这个基准值就可以了:

let max;
if (rect.width === rect.height) {
  // 如果是正方形,就简单的乘以根2,不再用下面的公式计算了,节省开销。
  max = rect.width * Math.SQRT2;
} else {
  max = Math.sqrt(rect.width * rect.width + rect.height * rect.height);
}
// 设涟漪尺寸为基准值的两倍
const size = max * 2 + "px";

然后计算出鼠标的坐标,再根据这个坐标把涟漪的位置摆到鼠标点击的位置上来:

const rect = this.$el.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;

最后,把计算好的这些数据放到涟漪上就可以了,为了能实现放大和消失的效果,还需要加两个表示状态的样式,就大工告成了,需要注意的是,在鼠标抬起来的时候,setTimeout 的值需要比你设定的过渡效果的时长要大。

最后要记得在 .ripple-ink 中把 overflow: hidden 加上,调试的时候可以去掉

单波纹完整代码

<template>
  <div class="ripple-ink" @mousedown="handleMouseDown" @mouseup="handleMouseUp">
    <div v-show="isShow" :style="inkStyle" :class="inkClass"></div>
  </div>
</template>

<script>
export default {
  name: "RippleInk",
  data() {
    return {
      isShow: false,
      isHolding: false,
      isDone: false,
      inkWidth: 0,
      inkHeight: 0,
      inkMarginLeft: 0,
      inkMarginTop: 0,
    };
  },
  computed: {
    inkStyle() {
      return {
        width: this.inkWidth,
        height: this.inkHeight,
        marginLeft: this.inkMarginLeft,
        marginTop: this.inkMarginTop,
      };
    },
    inkClass() {
      return [
        "ink",
        this.isHolding ? "is-holding" : "",
        this.isDone ? "is-done" : "",
      ];
    },
  },
  methods: {
    handleMouseDown(e) {
      this.isShow = true;
      this.$nextTick(() => {
        const rect = this.$el.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const y = e.clientY - rect.top;
        let max;
        if (rect.width === rect.height) {
          max = rect.width * Math.SQRT2;
        } else {
          max = Math.sqrt(rect.width * rect.width + rect.height * rect.height);
        }
        const size = max * 2 + "px";
        this.inkWidth = size;
        this.inkHeight = size;
        // 摆正涟漪的位置
        this.inkMarginLeft = x - max + "px";
        this.inkMarginTop = y - max + "px";
        this.isHolding = true;
      });
    },
    handleMouseUp() {
      this.isDone = true;
      setTimeout(() => {
        this.isShow = false;
        this.isDone = false;
        this.isHolding = false;
      }, 650);
    },
  },
};
</script>

<style lang="scss" scoped>
.ripple-ink {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  border-radius: inherit;
  overflow: hidden;
  .ink {
    width: 0;
    height: 0;
    position: absolute;
    border-radius: 50%;
    background-color: currentColor;
    transform: scale(0);
    transition: transform 0.6s ease-out, opacity 0.6s ease-out;
    &.is-holding {
      opacity: 0.4;
      transform: scale(1);
      &.is-done {
        opacity: 0;
      }
    }
  }
}
</style>

现在的单涟漪是有点问题的,在快速连续点击的时候会发现涟漪不正常。于是就有了优化版。

二、多波纹的优化版

那么,如何实现多波纹呢?

上面的单波纹版,涟漪是固定的一个子级,要实现多波纹,就需要有很多子级才可以,于是思路就是:用一个队列来存储涟漪,当鼠标按下时,把涟漪数据放到队列中,当涟漪效果执行完毕后,把它从队列头部移除就可以了,核心原理与单波纹一样。

<template>
  <div
    ref="ripple-ink"
    class="ripple-ink"
    @mousedown="handleMouseDown"
    @mouseup="handleMouseUp"
  >
    <div
      v-for="ink in inkQueue"
      :key="ink.key"
      :style="{
        width: ink.width,
        height: ink.height,
        marginLeft: ink.marginLeft,
        marginTop: ink.marginTop,
      }"
      :class="[
        'ink',
        ink.isHolding ? 'is-holding' : '',
        ink.isDone ? 'is-done' : '',
      ]"
    ></div>
  </div>
</template>

<script>
export default {
  name: "RippleInk",
  data() {
    return {
      inkQueue: [],
    };
  },
  methods: {
    handleMouseDown(e) {
      const rect = this.$el.getBoundingClientRect();
      const x = e.clientX - rect.left;
      const y = e.clientY - rect.top;
      let max;
      if (rect.width === rect.height) {
        max = rect.width * Math.SQRT2;
      } else {
        max = Math.sqrt(rect.width * rect.width + rect.height * rect.height);
      }
      const size = max * 2 + "px";
      this.inkQueue.push({
        key: Math.random(),
        width: size,
        height: size,
        marginLeft: x - max + "px",
        marginTop: y - max + "px",
        isHolding: false,
        isDone: false,
      });
      // 添加完队列数据后,在下一个事件周期开始动画
      setTimeout(() => {
        this.inkQueue[this.inkQueue.length - 1].isHolding = true;
      });
    },
    handleMouseUp() {
      this.inkQueue[this.inkQueue.length - 1].isDone = true;
      setTimeout(() => {
        this.inkQueue.shift();
      }, 650);
    },
  },
};
</script>

<style lang="scss" scoped>
.ripple-ink {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  border-radius: inherit;
  overflow: hidden;
  .ink {
    width: 0;
    height: 0;
    position: absolute;
    border-radius: 50%;
    background-color: currentColor;
    transform: scale(0);
    transition: transform 0.6s ease-out, opacity 0.6s ease-out;
    &.is-holding {
      opacity: 0.4;
      transform: scale(1);
      &.is-done {
        opacity: 0;
      }
    }
  }
}
</style>