【react.js + hooks】useRef 搭配 Houdini 创造 useRipple

发布时间 2023-12-31 02:08:22作者: Evanpatchouli

水波纹点击特效 really cool,实现水波纹的方案也有很多,笔者经常使用 material 组件,非常喜欢 mui 中的 ripple,他家的 ripple 特效就是通过 css Houdini 实现的。
今天,我们将复刻一个 ripple,并封装成 hooks 来使用!

CSS Houdini

首先,我们需要了解下 CSS Houdini 的相关知识:

Houdini 是一组底层 API,它们公开了 CSS 引擎的各个部分,从而使开发人员能够通过加入浏览器渲染引擎的样式和布局过程来扩展 CSS。Houdini 是一组 API,它们使开发人员可以直接访问CSS 对象模型 (CSSOM),使开发人员可以编写浏览器可以解析为 CSS 的代码,从而创建新的 CSS 功能,而无需等待它们在浏览器中本地实现。
Houdini 的 CSS Typed OM 是一个包含类型和方法的 CSS 对象、并且暴露出了作为 JavaScript 对象的值。比起先前基于字符串的,对 HTMLElement.style 进行操作的方案,对 JavaScript 对象进行操作更符合直觉。每个元素和样式表规则都拥有一个样式对应表,该对应表可以通过 StylePropertyMap 来获得。

<script>CSS.paintWorklet.addModule('csscomponent.js');</script>

csscomponents.js 里面定义一个 具名 类,然后应用到元素即可

li {
  background-image: paint(myComponent, stroke, 10px);
  --highlights: blue;
  --lowlights: green;
}

一个 CSS Houdini 的特性就是 Worklet (en-US)。在它的帮助下,你可以通过引入一行 JavaScript 代码来引入配置化的组件,从而创建模块式的 CSS。不依赖任何前置处理器、后置处理器或者 JavaScript 框架。

没有明白?没事,直接实操就明白了。

实现思路

点击元素时获取点击坐标(js 点击事件),将坐标,颜色,时常等参数传递给 css 变量,并从坐标处展开一个涟漪动画(houdini worklet),worklet 获取参数并渲染 canvas 动画即可。
涟漪变化的相关参数是时间,--ripple-time 将会在后面的js点击事件中实时更新。

创建 ripple 绘制 worklet

注册一个名为 "ripple" 的 paint 类,获取涟漪动画的 css 变量然后渲染涟漪。

// ripple-worklet.js
try {
  registerPaint(
    "ripple",
    class {
      static get inputProperties() {
        return ["--ripple-x", "--ripple-y", "--ripple-color", "--ripple-time"];
      }
      paint(ctx, geom, properties) {
        const x = parseFloat(properties.get("--ripple-x").toString());
        const y = parseFloat(properties.get("--ripple-y").toString());
        const color = properties.get("--ripple-color").toString();
        const time = parseFloat(properties.get("--ripple-time").toString());

        ctx.fillStyle = color;
        ctx.globalAlpha = Math.max(1 - time, 0);
        ctx.arc(x, y, geom.width * time, 0, 2 * Math.PI);
        ctx.fill();
      }
    }
  );
} catch (error) {
  if (error.name !== "DOMException") {
    throw error;
  }
}

封装 useRipple hook

为简化使用,将点击事件,涟漪样式都绑定到 ref 传递给需要使用涟漪的元素,并将应用 ripple worklet 的过程也添加到 useRipple 内;useRipple 再设置一下传参,传递 color(涟漪层颜色), duration(涟漪时常)和 trigger(触发时机),用于提高涟漪的可定制能力。
其中,为了让动画持续更新,通过 requestAnimationFrame 递归调用 animate 函数,实时更新 --ripple-time 参数

在外部定义 isWorkletRegistered 标志,避免重复注册 ripple worklet.

import { useRef, useEffect } from "react";

export type RippleConfig = {
  color?: React.CSSProperties["color"];
  duration?: number;
  trigger?: "click" | "mousedown" | "pointerdown";
};

let isWorkletRegistered = false;

const useRipple = <T extends HTMLElement = HTMLButtonElement>(
  config: RippleConfig = {
    color: "rgba(31, 143, 255, 0.5)",
    duration: 500,
  }
): React.RefObject<T> => {
  const ref = useRef<T>(null);
  const mounted = useRef<boolean>(false);

  useEffect(() => {
    if (mounted.current) return;
    try {
      if ("paintWorklet" in CSS && !isWorkletRegistered) {
        if (!isWorkletRegistered) {
          // @ts-ignore
          CSS.paintWorklet.addModule("houdini/ripple.js");
          isWorkletRegistered = true;
          console.log("Ripple worklet is registered");
        } else {
          console.warn("Ripple worklet is already registered");
        }
      } else {
        console.warn("Your browser doesn't support CSS Paint API");
      }
    } catch (error) {
      console.error(error);
    }
    mounted.current = true;
  }, []);

  useEffect(() => {
    const button = ref.current;
    if (!button) return;

    let animationFrameId: number | null = null;
    const handleClick = (event: MouseEvent) => {
      const rect = button.getBoundingClientRect();
      const x = event.clientX - rect.left;
      const y = event.clientY - rect.top;
      const startTime = performance.now();
      button.style.setProperty("--ripple-color", config.color ?? "rgba(31, 143, 255, 0.5)");
      button.style.setProperty("--ripple-x", `${x}px`);
      button.style.setProperty("--ripple-y", `${y}px`);
      button.style.setProperty("--ripple-time", "0");
      button.style.setProperty("background-image", "paint(ripple)");

      const animate = (time: number) => {
        const progress = (time - startTime) / (config.duration ?? 500); // Convert time to seconds
        button.style.setProperty("--ripple-time", `${progress}`);
        if (progress < 1) {
          animationFrameId = requestAnimationFrame(animate);
        } else {
          if (animationFrameId) {
            cancelAnimationFrame(animationFrameId);
          }
        }
      };

      animationFrameId = requestAnimationFrame(animate);
    };

    button.addEventListener(config.trigger ?? "mousedown", handleClick);

    return () => {
      if (animationFrameId) {
        cancelAnimationFrame(animationFrameId);
      }
      button.removeEventListener(config.trigger ?? "mousedown", handleClick);
    };
  }, []);

  return ref;
};

export default useRipple;

ripple-worklet 转 Blob

上面的 ripple.js 我们只能放在 public 下或者公网地址,通过路径传给 CSS.paintWorklet.addModule,放在 useRipple 目录下通过"./ripple.js" 传是无效的。有没有解决办法呢?注意,这个路径其实是 URL,我们可以通过 URL.createObjectURL 封装 ripple.js,再传给 addModule:

// rippleWorklet.ts
const rippleWorklet = URL.createObjectURL(
  new Blob(
    [
      `try {
    registerPaint(
      "ripple",
      class {
        static get inputProperties() {
          return ["--ripple-x", "--ripple-y", "--ripple-color", "--ripple-time"];
        }
        paint(ctx, geom, properties) {
          const x = parseFloat(properties.get("--ripple-x").toString());
          const y = parseFloat(properties.get("--ripple-y").toString());
          const color = properties.get("--ripple-color").toString();
          const time = parseFloat(properties.get("--ripple-time").toString());
  
          ctx.fillStyle = color;
          ctx.globalAlpha = Math.max(1 - time, 0);
          ctx.arc(x, y, geom.width * time, 0, 2 * Math.PI);
          ctx.fill();
        }
      }
    );
  } catch (error) {
    if (err.name !== "DOMException") {
      throw err;
    }
  }`,
    ],
    {
      type: "application/javascript",
    }
  )
);

export default rippleWorklet;

然后调整 useRipple:

CSS.paintWorklet.addModule(rippleWorklet); // "Houdini/ripple.js"

此时效果是一样的,不再需要额外配置 ripple.js.

使用示例

以下代码用 useRipple 创建了一个附带 ripple 特效的 div 组件,你可以用相同的方式为任意元素添加 ripple,也可以直接用这个 Ripple 组件包裹其他元素。

import { useRipple } from "@/hooks";

export default Ripple() {
  const rippleRef = useRipple<HTMLDivElement>();
  return(
    <div ref={rippleRef}>水波纹特效</div>
  )
}
.confirm-modal__actions__button--cancel {
    color: dodgerblue;
}

.confirm-modal__actions__button--confirm {
    color: #fff;
    background-color: dodgerblue;
}

.confirm-modal__actions__button {
    border-radius: 4px;
    margin-left: 0.5rem;
    text-transform: uppercase;
    font-size: 12px;
}

Bingo! 一个便捷的 useRipple 就这样实现了!

续集

有朋友说,mui 的水波鼠标不放开还有一层浅的留着,那我们可以再调整一下:

worklet 额外再接受一个 css 变量,用来判断鼠标是否放开(是否离开),不是的话,水波层的 alpha 就控制在一个固定的最低值即可:
我这边直接接受一个点击事件里面计算好的 alpha 最低值:

static get inputProperties() {
  return ["--ripple-x", "--ripple-y", "--ripple-color", "--ripple-time", "--ripple-alpha-min"];
}

调整 globalAlpha 的计算式:

const alphaMin = parseFloat(properties.get("--ripple-alpha-min").toString());
ctx.globalAlpha = Math.max(Math.max(1 - time, alphaMin), 0);

点击事件里面设置初始的 alphaMin:

const alphaMin = 0.3;
button.style.setProperty("--ripple-alpha-min", `${alphaMin}`);

此时,点击触发的水波最后保留在 0.3 的透明度。最后我们肯定要让它在合适的时机变成0。
然后定义一个函数 hiddenRipple 将 alphaMin 设为0:

const hiddenRipple = () => button.style.setProperty("--ripple-alpha-min", "0");

然后在鼠标松开和鼠标离开的事件中,调用 hiddenRipple即可:

button.removeEventListener("mouseup", hiddenRipple);
button.removeEventListener("mouseleave", hiddenRipple);

效果图:
useRipple
鼠标按住后松开,涟漪消失;鼠标按住并移出,涟漪消失。