如何实现一个丝滑的点击水波效果

简介: 本文为Varlet组件库源码主题阅读系列第九篇,读完本篇,可以了解到如何使用一个`div`创建一个点击的水波效果。
本文为Varlet组件库源码主题阅读系列第九篇,读完本篇,可以了解到如何使用一个 div创建一个点击的水波效果。

Varlet组件库提供了一个使元素点击时生成水波扩散效果的指令:

<template>
  <div v-ripple>点击</div>
</template>

接下来就从源码角度看看它是如何实现的。

首先在指令所绑定的目标元素被挂载的时候会执行如下方法:

function mounted(el: RippleHTMLElement, binding: DirectiveBinding<RippleOptions>) {
  // 给元素上添加一个对象记录一些数据
  el._ripple = {
    tasker: null,
    ...(binding.value ?? {}),
    touchmoveForbid: binding.value?.touchmoveForbid ?? context.touchmoveForbid,
    removeRipple: removeRipple.bind(el),
  }
  // 给元素绑定了一些事件
  el.addEventListener('touchstart', createRipple, { passive: true })
  el.addEventListener('touchmove', forbidRippleTask, { passive: true })
  el.addEventListener('dragstart', removeRipple, { passive: true })
  document.addEventListener('touchend', el._ripple.removeRipple, { passive: true })
  document.addEventListener('touchcancel', el._ripple.removeRipple, { passive: true })
}

主要就是绑定了一些事件,处理函数一共有三个,从函数名中也可以大致看出其作用。

注意看addEventListener方法的第三个参数中都设置了passive = true,这个选项用来告诉浏览器我们的处理函数中不会调用preventDefault方法,这么做有什么好处呢?比如touch事件或scroll事件的默认行为都会触发页面的滚动,如果调用了preventDefault方法,那么就会阻止滚动,但问题是浏览器并不知道我们有没有在事件处理函数中调这个方法,那么就必须等待函数执行完毕才知道,有时候函数的执行是比较耗时的,这样就会导致页面卡顿,所以如果我们的处理函数中明确不会调用preventDefault方法,那么就通过passive标志直接告诉浏览器,这样浏览器就不会等待,直接进行滚动,可以显著提升页面性能和体验。

先看看touchstart事件的处理方法createRipple

function createRipple(this: RippleHTMLElement, event: TouchEvent) {
  // 首先获取该元素上存储的数据
  const _ripple = this._ripple as RippleOptions
  // 先移除上一个水波
  _ripple.removeRipple()
  // 如果禁用或者上一个水波任务还未执行则返回
  if (_ripple.disabled || _ripple.tasker) {
    return
  }
  // 水波任务
  const task = () => {
    // ...
  }
  // 保存定时器
  _ripple.tasker = window.setTimeout(task, 60)
}

当我们触摸点击一个元素的时候,会先移除该元素的上一个水波,然后添加一个新的水波任务,这个任务会在一个60ms的定时器后执行,然后把定时器id保存起来,为什么不立即执行呢,应该是为了能够取消吧,比如想在touchmove情况下不开启水波效果,那么就可以通过取消这个定时器来实现,看一下touchmove事件的处理函数forbidRippleTask

function forbidRippleTask(this: RippleHTMLElement) {
  const _ripple = this._ripple as RippleOptions
  // 是否需要在触摸移动时禁用水波效果
  if (!_ripple.touchmoveForbid) {
    return
  }
  // 如果在60ms内触摸移动了就会取消定时器,自然水波效果就不会有了
  _ripple.tasker && window.clearTimeout(_ripple.tasker)
  _ripple.tasker = null
}

接下来看看task方法:

function createRipple(this: RippleHTMLElement, event: TouchEvent) {
  //...

  const task = () => {
    // 定时器任务执行了则把保存的定时器id清空
    _ripple.tasker = null
    // 计算一些数据
    const { x, y, centerX, centerY, size }: RippleStyles = computeRippleStyles(this, event)
    // 创建一个div
    const ripple: RippleHTMLElement = document.createElement('div')
    // 添加一个var-ripple类名
    ripple.classList.add(n())
    // 设置透明度为0,即全透明
    ripple.style.opacity = `0`
    // 设置位置及缩放
    ripple.style.transform = `translate(${x}px, ${y}px) scale3d(.3, .3, .3)`
    // 设置大小
    ripple.style.width = `${size}px`
    ripple.style.height = `${size}px`
    // 设置颜色
    _ripple.color && (ripple.style.backgroundColor = _ripple.color)
    // 记录创建时间
    ripple.dataset.createdAt = String(performance.now())
    // 设置被点击元素的样式
    setStyles(this)
    // 将水波元素添加到被点击元素内
    this.appendChild(ripple)
    // 20ms后修改水波元素的样式,达到水波的扩散动画效果
    window.setTimeout(() => {
      ripple.style.transform = `translate(${centerX}px, ${centerY}px) scale3d(1, 1, 1)`
      ripple.style.opacity = `.25`
    }, 20)
  }

  //...
}

可以看到所谓水波就是一个div,总体的流程为先创建一个div元素,然后设置它的透明度为0、初始位置、缩放、大小、背景颜色,然后添加为被点击元素的子元素,最后在20ms以后修改div的位置、缩放、透明度,只要设置了它的transation过渡属性即可实现过渡效果,也就是水波扩散的效果,样式是通过类名var-ripple设置的:

:root {
  --ripple-cubic-bezier: cubic-bezier(0.68, 0.01, 0.62, 0.6);
  --ripple-color: currentColor;
}

.var-ripple {
  position: absolute;// 设置为绝对定位
  transition: transform 0.2s var(--ripple-cubic-bezier), opacity 0.14s linear;// 设置过渡效果
  top: 0;
  left: 0;
  border-radius: 50%;// 设置为圆形
  opacity: 0;
  will-change: transform, opacity;
  pointer-events: none;// 禁止响应鼠标事件
  z-index: 100;
  background-color: var(--ripple-color);// 背景颜色
}

可以看到水波元素为绝对定位,另外位置的过渡时间为200ms,透明度的过渡时间为140ms

接下来看看其中调用的几个函数。

首先是调用computeRippleStyles方法计算一些基本数据:

function computeRippleStyles(element: RippleHTMLElement, event: TouchEvent): RippleStyles {
  // 被点击元素距离屏幕顶部和左侧的距离
  const { top, left }: DOMRect = element.getBoundingClientRect()
  // 被点击元素的宽高
  const { clientWidth, clientHeight } = element
  // 计算水波圆的半径
  const radius: number = Math.sqrt(clientWidth ** 2 + clientHeight ** 2) / 2
  // 直径
  const size: number = radius * 2
  // ...
}

水波的直径是根据勾股定理计算的:

function computeRippleStyles(element: RippleHTMLElement, event: TouchEvent): RippleStyles {
  // ...
  // 手指点击的位置相对于被点击元素的坐标
  const localX: number = event.touches[0].clientX - left
  const localY: number = event.touches[0].clientY - top
  // 水波元素初始位置
  const x: number = localX - radius
  const y: number = localY - radius
  // 水波元素最终位置
  const centerX: number = (clientWidth - radius * 2) / 2
  const centerY: number = (clientHeight - radius * 2) / 2
  return { x, y, centerX, centerY, size }
}

size为水波圆的直径;手指点击的位置是水波圆初始的中心点,然后计算其左上角坐标x、y为水波元素的初始位置;水波圆的最终中心点其实就是被点击元素的中心点,换算成左上角坐标centerX、centerY即为水波元素的最终位置。因为水波元素为被点击元素的子元素,所以这些坐标都是相对于被点击元素的左上角坐标计算的:

从绿色的圆过渡成红色的圆,透明度、大小、位置的变化就是水波的扩散效果。

将水波元素添加到被点击元素内前还调用了setStyles方法:

function setStyles(element: RippleHTMLElement) {
  const { zIndex, position } = window.getComputedStyle(element)

  element.style.overflow = 'hidden'
  element.style.overflowX = 'hidden'
  element.style.overflowY = 'hidden'
  position === 'static' && (element.style.position = 'relative')
  zIndex === 'auto' && (element.style.zIndex = '1')
}

这个函数做的事情主要是检查和设置被点击元素的一些样式,首先溢出需要设置为隐藏,否则水波圆的扩散就会溢出元素完整显示出来,这显然不好看,然后前面提到过水波元素为绝对定位,所以被点击元素的定位不能是静态定位,最后的层级设置笔者暂时没有想出来是为了解决什么问题。

到这里,当我们手触摸元素时,水波效果就创建完成了,接下来是移除操作,看一下removeRipple方法:

const ANIMATION_DURATION = 250

function removeRipple(this: RippleHTMLElement) {
  const _ripple = this._ripple as RippleOptions

  const task = () => {
    // 获取水波元素
    const ripples: NodeListOf<RippleHTMLElement> = this.querySelectorAll(`.${n()}`)
    if (!ripples.length) {
      return
    }
    // 最后一个水波
    const lastRipple: RippleHTMLElement = ripples[ripples.length - 1]
    // 计算延迟时间
    const delay: number = ANIMATION_DURATION - performance.now() + Number(lastRipple.dataset.createdAt)
    // 延迟后将水波的透明度设置为0
    setTimeout(() => {
      lastRipple.style.opacity = `0`
      // 再次延迟后移除水波元素
      setTimeout(() => lastRipple.parentNode?.removeChild(lastRipple), ANIMATION_DURATION)
    }, delay)
  }
  // 创建任务的定时器id存在则等待60ms
  _ripple.tasker ? setTimeout(task, 60) : task()
}

先回顾一下创建水波的各个阶段的耗时,当我们第一次点击元素时,等待60ms后会创建水波元素,然后再等待20ms后会开始进行水波的扩散效果,动画耗时200ms结束,如果我们在60ms内进行第二次点击不会创建第二个水波,因为前一个水波任务还未执行,如果是在60ms后第二次点击,会先调用removeRipplie移除上一个水波,然后重复第一个水波的创建流程:

每次执行removeRipple方法只需要移除当前最后一个水波即可,之前的水波会由之前的task移除。

接下来详细看看整个过程。

当手指第一次触摸点击元素时会执行createRipple方法,方法内会先执行removeRipple方法,此时_ripple.tasker不存在,会立即执行removeRippletask方法,但是目前并没有水波元素,所以这个函数会直接返回,removeRipple方法执行完毕。

接下来会创建一个60ms的定时器,等待执行createRippletask,如果我们在60ms内就松开了手指,那么又会执行removeRipple方法,此时_ripple.tasker存在,所以removeRippletask方法也会等待60ms再执行;如果我们是在60ms后才松开手指,那么_ripple.tasker不存在,会立即执行removeRippletask方法,该方法内会获取最后一个水波元素,也就是刚刚创建的水波元素,然后计算delay

delay = ANIMATION_DURATION - (performance.now() - Number(lastRipple.dataset.createdAt))

performance.now() - Number(lastRipple.dataset.createdAt)代表此刻到创建水波时过去的时间,ANIMATION_DURATION减去它即表示250ms还剩下的时间,因为前面提到了水波从创建到扩散完成整个过程大概耗时20ms + 200ms = 220ms,所以延迟dealy时间,也就是等待水波动画完成后再让水波消失,避免水波还未扩散完成就消失的情况,修改水波的透明度为0,透明度动画耗时140ms,所以再等待250ms将水波元素移除。

如果在60ms内松开手指又立即再次触摸元素,那么又会执行createRipple方法,同样又会先执行removeRipple方法,此时前一个创建水波的task任务还未执行,_ripple.tasker存在,所以removeRippletask方法会等待60ms再执行,这个task任务其实和松开手指时触发的task任务重复了,相当于两个task移除同一个水波元素,不过问题也不大。

因为上一个水波的task还未执行,所以createRipple会直接返回。

如果在60ms后再次触摸元素,执行removeRipple_ripple.tasker不存在,会立即执行task方法,同样,这个task任务也会和松开手指触发的task任务重复。

此时_ripple.tasker不存在,所以创建第二个水波的任务会被添加到定时器里,当第二次松开手指时,执行removeRiplle会删除第二个水波。

更多次重复触摸元素时以此类推,会不断创建水波,水波动画结束后也会不断被删除。

在目标元素被卸载时会执行unmounted方法:

function unmounted(el: RippleHTMLElement) {
  el.removeEventListener('touchstart', createRipple)
  el.removeEventListener('touchmove', forbidRippleTask)
  el.removeEventListener('dragstart', removeRipple)
  document.removeEventListener('touchend', el._ripple!.removeRipple)
  document.removeEventListener('touchcancel', el._ripple!.removeRipple)
}

主要是移除绑定的事件。

到这里,水波效果的创建和移除就都介绍完了,可以看到这种实现方式对目标元素还是有一定要求的,如果目标元素的样式布局需要设置positionoverflowz-index属性为不符合要求的值,那么直接修改可能就会导致样式出现问题,并且卸载时也没有进行恢复,这是不是也算是一个小bug

相关文章
|
19天前
|
JavaScript
手搓日历组件,大屏样式最佳解决方案!
【10月更文挑战第6天】手搓日历组件,大屏样式最佳解决方案!
36 4
手搓日历组件,大屏样式最佳解决方案!
|
2月前
|
前端开发 图形学 开发者
【独家揭秘】那些让你的游戏瞬间鲜活起来的Unity UI动画技巧:从零开始打造动态按钮,提升玩家交互体验的绝招大公开!
【9月更文挑战第1天】在游戏开发领域,Unity 是最受欢迎的游戏引擎之一,其强大的跨平台发布能力和丰富的功能集让开发者能够迅速打造出高质量的游戏。优秀的 UI 设计对于游戏至关重要,尤其是在手游市场,出色的 UI 能给玩家留下深刻的第一印象。Unity 的 UGUI 系统提供了一整套解决方案,包括 Canvas、Image 和 Button 等组件,支持添加各种动画效果。
117 3
|
3月前
|
API
【threejs教程】场景视角切换的神器:轨道控制器
【8月更文挑战第5天】threejs教程:场景视角切换的神器,轨道控制器
140 1
【threejs教程】场景视角切换的神器:轨道控制器
|
3月前
|
前端开发 UED
设计新潮流:CSS动画毛玻璃按钮,展开效果引人入胜!
设计新潮流:CSS动画毛玻璃按钮,展开效果引人入胜!
|
6月前
|
定位技术
Pyglet综合应用|推箱子游戏地图编辑器之图片跟随鼠标
Pyglet综合应用|推箱子游戏地图编辑器之图片跟随鼠标
58 0
|
6月前
|
开发工具 索引
点击一个消除游戏图标时,背后都发生了什么
点击一个消除游戏图标时,背后都发生了什么
64 1
|
11月前
|
机器学习/深度学习 算法 图形学
Unity小游戏——无限滚动的背景的改良
Unity小游戏——无限滚动的背景的改良
|
数据安全/隐私保护 iOS开发 MacOS
CocosCreator3.8研究笔记(二十四)CocosCreator 动画系统-动画编辑器实操-关键帧实现动态水印动画效果(2)
CocosCreator3.8研究笔记(二十四)CocosCreator 动画系统-动画编辑器实操-关键帧实现动态水印动画效果
133 0
|
数据安全/隐私保护
CocosCreator3.8研究笔记(二十四)CocosCreator 动画系统-动画编辑器实操-关键帧实现动态水印动画效果(1)
CocosCreator3.8研究笔记(二十四)CocosCreator 动画系统-动画编辑器实操-关键帧实现动态水印动画效果
129 0
|
弹性计算 前端开发 JavaScript
如此丝滑的按钮交互效果
如此丝滑的按钮交互效果
61 0