Design System 101 - Ripple

前言

Ripple Effect 是 Material Design 中的一個動畫效果,當使用者點擊 Button 時,會有一個水波紋的效果,讓使用者知道自己點擊的位置。本篇將會介紹如何來實作 Ripple 組件!

API 設計

Ripple 的 API 設計相對簡單,其功能主要會有以下:

  1. 首先我們需要一個元素作為其附著的範圍,讓 Ripple 組件可以在點擊時呈現水波紋的動畫。
  2. 讓使用者能夠客製化水波紋的顏色。
屬性描述型別預設值
colorRipple 的顏色string-
targetRipple 的附著範圍,Ripple 組件會在這個範圍內呈現動畫node-
classNameRipple Container 的額外樣式string-

HTML 結構

我們會透過 <span /> 定義其動畫擴張的範圍,由於 Ripple 只是屬於動畫呈現組件,可以用 aria-hidden 來隱藏其元素,增強網頁的無障礙 (Accessibility) 使用性。

<-- container -->
<span aria-hidden="{true}">
<-- animation effect -->
<span />
</span>

使用方式

() => (
<div ref={containerRef}>
<span>Ripple Effect</span>
<Ripple target={containerRef} />
<div>
)

建立 Ripple 組件

第一步驟 - 透過 plop 建立 Ripple 的組件

design-system > pnpm generate // name: ripple
design-system > cd packages/ripple
design-system/packages/ripple > pnpm i // 安裝相依套件

第二步驟 - CSS

透過 CSS 來實作 Ripple 的動畫效果,首先先定義 Ripple 的容器,並且透過 overflow: hidden 來限制 Ripple Effect 只會在容器內呈現。

.tocino-Ripple__container {
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
height: 100%;
width: 100%;
overflow: hidden;
pointer-events: none;
}

再來就是 Ripple 的動畫效果,當 style 改變時,會透過 transition 來呈現動畫。

.tocino-Ripple {
position: absolute;
top: 0;
left: 0;
border-radius: 50%;
opacity: 0;
pointer-events: none;
transform: scale(0.0001, 0.0001);
&.tocino--Ripple-animating {
transform: none;
transition: transform 0.15s linear, width 0.15s linear, height 0.15s linear, opacity 0.15s linear;
will-change: transform, width, height, opacity;
}
&.tocino--Ripple-visible {
opacity: 0.3;
}
}

第三步驟 - 核心邏輯

最後我們就需要監聽使用者點擊或是觸碰的事件,來觸發 Ripple 的動畫效果!

useRipple 將主要核心邏輯封裝載該 hook 中:

狀態設計

  • rippleStyle: 存放 Ripple 的樣式,並當 Style 改變時,會觸發動畫效果。
  • rippleIsVisible: 控制 Ripple 是否可見。
  • rippleElRef: Ripple 元素的參考。
export const useRipple = ({ target, color }) => {
const [rippleStyle, setRippleStyle] = useState({});
const [rippleIsVisible, setRippleIsVisible] = useState(false);
const rippleElRef = useRef(null);
...
}

事件邏輯

接著透過 target 的傳入,我們可以使用 useEffect 來訂閱 tocuh 以及 mouse 事件。

useEffect(() => {
target.current?.addEventListener('touchstart', showRipple, { passive: true });
target.current?.addEventListener('mousedown', showRipple, { passive: true });
target.current?.addEventListener('mouseup', hideRipple, { passive: true });
target.current?.addEventListener('mouseleave', hideRipple, { passive: true });
return () => {
target.current?.removeEventListener('touchstart', showRipple);
target.current?.removeEventListener('mousedown', showRipple);
target.current?.removeEventListener('mouseup', hideRipple);
target.current?.removeEventListener('mouseleave', hideRipple);
};
}, []);

當使者點擊容器時,會觸發 showRipple 事件,並且透過 rippleElRef 來取得 ripple 的元素,並且計算出 ripple 的位置。

const showRipple = useCallback(
(evt) => {
const buttonEl = target.current;
const offset = domUtils.offset(buttonEl);
const clickEvent = evt.type === 'touchstart' && evt.touches ? evt.touches[0] : evt;
const radius = Math.sqrt(offset.width * offset.width + offset.height * offset.height);
const diameterPx = radius * 2 + 'px';
setRippleStyle({
top: Math.round(clickEvent.pageY - offset.top - radius) + 'px',
left: Math.round(clickEvent.pageX - offset.left - radius) + 'px',
width: diameterPx,
height: diameterPx,
backgroundColor: color,
});
setRippleIsVisible(true);
},
[rippleElRef, color],
);

最後在事件結束後,觸發 hideRipple 事件,讓 Ripple Effect 消失。

const hideRipple = useCallback(() => {
setRippleIsVisible(false);
}, []);
import React from 'react';
import { Ripple } from './ripple';

export default () => {
  const containerRef = React.useRef(null);

  return (
    <div
      ref={containerRef}
      style={{
        position: 'relative',
        width: '200px',
        height: '200px',
        border: '1px solid gray',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
        borderRadius: '50%',
      }}
    >
      <span>Ripple Effect</span>
      <Ripple target={containerRef} />
    </div>
  );
};

開啟 Storybook & 測試

design-system/ pnpm run test -w
design-system/ pnpm run storybook

透過 changeset 來產生 changelog 以及 commit

pnpm changeset