🎇

Android 12みたいなリップルエフェクトをWebで実現するReact用ライブラリを作りました。

に公開

ざっくりまとめると...

Android 12以降に導入された、キラキラと光る演出付きのリップルエフェクトをWeb上で再現するReact用ライブラリを作成しました。


ドキュメント
https://m3ripple.js.org/
npm
https://www.npmjs.com/package/@m_three_ui/m3ripple
GitHubリポジトリ
https://github.com/yuyake-litrain/m3ripple


実際に試してみる

CodePenにデモを上げているので、ここで試すことができます。

使い方

CodePenのコードが参考になります。
または、こちらまで一気に飛んで確認してください。
ドキュメントサイトもぜひ見てみてください。

Liquid Glassが話題な今、3年前からあるものをWebで再現する。

はじめまして、Litrainと申します。
突然ですが、 Material Design は好きですか?

Appleが新しい Liquid Glass を発表し、おそらく今一番話題のデザインシステムとなっている中、私はAndroid 12で導入された、キラキラ付き Ripple EffectをWeb上で再現することに必死になっていました。

Android 12で、Googleが新たなMaterial You デザインシステムと同時に導入したキラキラ輝くエフェクト入りのRipple Effect ―
(詳しくは以下の記事・動画15:50〜に載っています)
https://www.phonearena.com/news/Android-12-ripple-sparkle-effect-buttons_id132420
https://youtu.be/D2cU_itNDAI?t=950

私はMaterial Youの大幅なデザイン変更に戸惑いながら実際のOSに触れてみて、このRipple Effectがとても好きになりました。おしゃれで楽しくて、AndroidにこれまでなかったUIに質感を加える存在だと思います。やっぱり質感って大事です。

反対にネットでは(特に海外で)不評だったようです。
グラフィックのバグみたいだとか、必要ないとか奇妙だとか、なかなか厳しい意見が多く、Androidもバージョンアップを経て、今ではこのキラキラもだいぶ大人しくなってしまいました。

とはいえ、現在も控えめながらこのキラキラは存在するし、第一私が好きなので、「これをWebでも再現してみよう!」と思って作業することにしました。

なんでWebで?

Vue用のMaterial Design系ライブラリである VuetifyでのRipple Effectの再現 を見て感動したことが大きいです。
美しく再現されていて、操作するのがものすごく楽しいし気持ちが良く、これと同じように、「Material 3によく似合うこのRipple EffectもWebで使えたら...!」と思ったわけです。

Ripple Effectを観察する

さて、Sparkleは置いといて、ただのMaterial 2時代からあったRipple Effectを実装すると考えたとき、あなたならまずどうしますか?

...私はとにかくこだわって作るのが好きなので、まずは観察してなぜ操作していて気持ちいいのかRipple Effectとは一体どんなものなのか考えてみます。

Ripple Effectを考える

🙃「リップルエフェクトなんて、タップしたところから円が端っこまで広がって、消えればいいんでしょ?」

いいえ、違います(きっぱり)

きちんと見ると、思ったよりRipple Effectが奥深いものだと気づきます。
ここでリップルエフェクトとはどんなものなのか、整理してみましょう。


  1. ユーザがタップ・クリックを開始する(mousedown/touchstartイベントが発火)

  2. 円(今後はこれをRippleと呼びます)が出現する
    サイズは0からのスタートとは限らない

  3. Rippleが大きくなる
    タップしたところの座標から、タップした要素の最も遠い角の2倍の大きさ(width)まで広がる

  4. Ripple Effectが最大まで広がってもなお、ユーザがタップまたはクリックを続けている
    Rippleは消さず、最大まで広がったRippleを残し続ける

  5. ユーザがタップ・クリックを終える(mouseup/touchendイベントが発火) or ユーザの指が何らかの理由で該当の要素から離れる(touchcancelイベントが発火) or マウスがクリックされたままの状態で要素から離れる(mouseleaveイベントが発火)

    1. Rippleが最大まで広がり終えている場合
      Rippleをフェードアウトさせて消す

    2. Rippleが最大まで広がっていない場合
      Rippleが最大まで広がるのを待ってからフェードアウトさせて消す。


これがRipple Effectです。 これのどれかが欠けると不完全だと、私は思っています。
というか何かまだ足りないものがあるかもしれません。

試したほうが実感できると思うので、ぜひ試してみてください。

処理のシーケンス以外の重要事項

Ripple Effectの特徴はこれだけではありません。
アニメーションを伴うエフェクトである以上、イージング時間が重要になってきます。

イージング

イージングとは、アニメーションの進行に緩急を与える方法です。

https://www.youtube.com/watch?v=vHprsEjFJp0

アニメーションに緩急をつけることで、いきいきとした、現実の力学と近い動きにすることができます。
すべてのアニメーションにイージングが必要だというわけではなく、ないほうがいい場合が多くあることに気をつける必要はありますが、リップルエフェクトにはイージングが必要です。あったほうが気持ちがいいです。

そこでイージングをつけるわけですが、リップルエフェクトはユーザからの入力を受け取ったと、ユーザに伝えるためのものです。
そのため、円の拡大・縮小のアニメーションは、タップしてすぐに一気に加速したほうが気持ちがいいでしょう。その後は負の向きに加速度のかかったようなイージングを設定します。

cubic-bezier(0, 0.49, 0, 1)

グラフはここから確認できます:
https://www.easings.dev/create/cubic-bezier#x1=0&y1=0.49&x2=0&y2=1
そのほかにも、フェードアウト・フェードインにも適宜イージングを追加します。

時間

もともと存在しなかった要素が、アニメーションを伴って登場するときには、どんなアニメーションをつけると良いと思いますか?

🤔 「もともと存在しなかったわけだから、大きさ0から拡大とか、画面外のx: (負の数)から移動とかかな?」

確かに!
でも、案外そうとも限りません

これは映像制作においてもよく意識されていることなのですが、人間の脳は映像を補完します
つまり、足りない部分を補って見せてくれるのです。だから実は、画面外でなくとも、大きさ0でなくてもいいんです。
※ ゆっくり・勢いがついていない登場アニメーションで表示させる場合は、補完してくれないのでお気をつけて!

🤨 「...でも、なんでそんなことをするの?」

例えば、大きさ0で登場し、200まで拡大されるRippleと、大きさ70で登場し、200まで拡大されるRippleがあったとしましょう。脳が補完するので、これはどちらも違和感なく見ることができます。
ただ、後者のほうがレスポンス良く感じます。 クリックした直後に、絶対に70の大きさで表示されることになるので、もたつきを感じづらくなり、より気持ちのいいRipple Effectにすることができるのです。
だから今回は、私はRippleを最大の\frac 1 6の大きさからアニメーションを開始させるようにしました。

https://youtu.be/DFyoa0IDD4g

Reactで実装する

Ripple Effect について詳しくなったので、早速Reactで実装していきました。
Rippleの拡大縮小は Web Animation API を使ってアニメーションさせることにします。
理由はライブラリに依存する必要もなく使えてわかりやすく、アニメーションの終了を finishedプロパティ で待機することができるからです。
#Ripple Effectを考えるで書いた通り、アニメーションが終了しているかを確認する場面があるため、このプロパティは重要です。

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/base/ripple.tsx#L79-L111

”Reactっぽい” 実装

あまりReactをよく知っているわけではないものの、Reactは与えられた状態からUIを構築するというイメージがあったため、Ripple Effectを効かせる対象のコンポーネントとして <RippleContainer /> を用意し、現在表示中のRippleを状態として持たせることにしました。
こうすることで、Stateとして持っている ripples 配列の RippleObj オブジェクトを追加したり削除したりすることでRippleが操作できます。

上のアニメーションのコードを見てもらうとわかりますが、<Ripple /> コンポーネントの useEffect() で、コンポーネントが表示された瞬間アニメーションを発動させているのはこのためです。

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L121-L138

Rippleを消す前にはフェードアウト アニメーションの処理が必要になるので、tobeDeletedというプロパティを用意し、 tobeDeletedtrue になった場合に <Ripple /> コンポーネントでフェードアウト アニメーションを行い、完了したら親要素( <RippleContainer /> )の関数を子要素( <Ripple /> )から呼び出して、親要素の状態である ripples から RippleObj を削除しています。

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/base/ripple.tsx#L113-L120

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/base/ripple.tsx#L44-L77


簡単なシーケンス

  1. クリック/タップ等でripplePerform()が呼ばれる
  2. RippleObjの配列をStateとして持っているので、ripplePerform()からsetRipples()を呼び出してrippleを追加する。
  3. rippleのアニメーションが終わり、かつクリックやタップが続けられていない場合、子要素でフェードアウト アニメーションを行い、完了後に setRipples() で状態 ripples からRippleを削除、画面からRippleが消える。

Sparklesの処理と、Rippleのサイズ計算など

キラキラ(今後は Sparkles と呼びます)が今回の核心なのに、まだ出てきていなかったですね。sparkles.tsに詳しい処理は載っているので確認していただきたいのですが、ポイントをいくつか紹介します。

SparklesをCanvasに描画

Sparklesは各 <Ripple /> コンポーネントの中にある <SparkleCanvas /> の一枚のCanvasに描画しています。
これは試行錯誤の結果で、 <div> タグを大量に配置してみたり、Canvasに描画しつつも、(Canvasでのアニメーションの経験がなかったために)毎フレームCanvasを再生成してみたりしていましたが、前者はFirefoxではカクカクで厳しく、後者はだいぶマシになったものの気分的にはまだやれるなという感じで、結果的に頑張って一枚のCanvasの中で requestAnimationFrame() を使ってアニメーションさせるのに落ち着きました。

計算処理

描画タイミングにおけるRippleの半径を半径とする円の形に散らばって点が打たれるように実装しました。
円は成分表示すると\sin\theta\cos\thetaで表せるので\theta = \text{0\degree\textasciitilde360\degree}の範囲で乱数を生成して、円周上の点を決定し、乱数でずらしました。
ちなみに乱数を足すだけだと外方向にしか広がらないので、足すだけでなく引いてもいます。
https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/ripple/sparkles.ts#L34-L56

Ripple関係の計算

三平方の定理で最大のwidthを算出


ripple.tsで詳しく見ることができますが、リップルの大きさを計算するときは、最大までRippleが大きくなったとき、絶対にRippleの端っこが見えてしまわないように、クリック地点から最も遠い角までの距離の2倍(widthは直径なので)をRippleの最大の大きさに指定してあげなければいけません。
そこで三平方の定理を使います。

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/ripple/utils.ts#L34-L36

offsetを常に <RippleContainer /> 基準に。

また、クリックされた座標を始点にRippleをアニメーションさせるために offset を使いたいのですが、 <RippleContainer /> にイベントハンドラを登録していても、その子要素がクリックされた場合、 event.offsetXevent.offsetY は子要素基準の offset となるため、リップルがうまく配置できません。
そのため、常に親要素( <RippleContainer /> )が基準になるように、親要素の位置を getBoundingClientRect() で取得し、 event.clientX を引くという方法を使っています。

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L93-L100
https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/ripple/utils.ts#L42-L54

https://qiita.com/yukiB/items/cc533fbbf3bb8372a924

ハマったところ

useEffect()が2回呼ばれて、リップル消去の挙動が変

<RippleContainer />のStateとしてRippleを管理している都合から、Rippleを追加したり削除したりする中で生じる再レンダーで、ある関数( deleteRipple() )が何度も再定義され、その関数を渡している <Ripple />useEffect()が何度も呼ばれて挙動がおかしくなりました。

何度も再定義されていた関数に useCallback を使って再レンダー時に再定義させないようにしたら正常に動作するようになりましたが、 useCallbackパフォーマンスの最適化のみに使用されるべきだとドキュメントに書かれていたので、加えて useEffect() が複数回呼ばれても処理は一回のみ実行されるように Ref を使って実装しました。

具体的には、削除処理を行うかを指定する tobeDeletedtrue のときに何度も再レンダーされ useEffect() が呼ばれると、何度もRippleの削除処理が行われて意図しない挙動になったため、削除処理は一回のみ実行されるように Ref で管理することにしました。
https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/base/ripple.tsx#L113-L120

タッチデバイスで、スクロール時にちょっと触れただけでもリップルが発動して邪魔

touchstart でRipple表示、touchend でRipple消去という実装をシンプルにしただけでは、スクロール時に要素に一瞬触れただけでもRipple Effectが発動してしまいました。
これだと、タップしたつもりはないのにタップされた際の効果が発動していることになるので、ユーザは恐怖です。
「間違えてなにか危ないボタンを無意識に押してたのでは...?!」となってしまいます。

これに以下の手順で対処しました。

  1. touchstart が呼ばれる
  2. 0.1秒待つ
    この0.1秒の間に touchmove が呼ばれた場合、 isScrolltrue する
  3. isScroll === false の場合だけリップルエフェクトを発動させる
  4. isScroll をfalseにする

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L169-L206
https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L208-L210

タッチデバイスで、リップルが二重発動

タッチデバイスでは touchstart -> touchend -> mousedown -> mouseup と、touch関係だけでなくmouse関係のイベントまで呼ばれてしまいます。
Ripple Effectは touchstart / mousedown どちらでも始まるので、タッチデバイスでは二重にRippleが表示されてしまいます。
これを、touchstart が呼ばれてから1秒間は mousedown によるRipple Effectの発動を無効化する処理で対応しました。

https://github.com/yuyake-litrain/m3ripple/blob/239e360ae431dbb56aafd9b95e33244e86aa85ae/lib/components/ripple_container.tsx#L195-L197

いかがでしたか?

これでだいたい、Ripple Effectの奥深さと、私がこれをライブラリにして配布するべきだと思った理由はおわかりいただけたでしょうか...?
とにかくこれを、特にRipple Effectがメインのプロジェクトではない場合、自分で実装するのはとにかく辛いです。だから、せっかく実装したならライブラリにしてしまおう、という発想でライブラリ化してみました。

めちゃくちゃ頑張って作ったサイト

https://m3ripple.js.org/

npmからインストールできます。

https://www.npmjs.com/package/@m_three_ui/m3ripple

GitHubはこちら。

https://github.com/yuyake-litrain/m3ripple

使い方

インポートして、Rippleを適用させたい要素を<RippleContainer />で包んでください。

import { RippleContainer } from '@m_three_ui/m3ripple'; //インポート!
import styles from './some_css_file.module.css'; //CSSを当ててね

const YourComponent = () => {
  return (
    <RippleContainer
      isMaterial3 = {true}
      beforeRippleFn = {(event) =>{}}
      className = {styles.rippleContainer}
      rippleColor = "hsla(29,81%,84%,0.15)"
      sparklesColorRGB = "255 255 255"
      opacity_level1 = "0.4"
      opacity_level2 = "0.1"
      sparklesMaxCount = 2048
      divProps = {{}}
      onMouseDown = {() => {}}
      onTouchStart = {() => {}}
      onTouchMove = {() => {}}
      onMouseUp = {() => {}}
      onTouchEnd = {() => {}}
      onMouseLeave = {() => {}}
      onTouchCancel = {() => {}}
    >
      <div className={styles.children} />
    </RippleContainer>
  );
};

export default YourComponent;

プロパティの説明

プロパティ名 オプション 説明 デフォルトの値 受け付ける型
isMaterial3 Material 3(キラキラ付き)のリップルエフェクトにするかどうか true boolean
beforeRippleFn Ripple Effectが表示される直前に実行される関数(影つけるときとかに使います) ()=>{} (event: React.MouseEvent | React.TouchEvent) => void
className RippleContainerはDiv要素にレンダリングされるので、そのclassName "" string
children 子要素 undefined ReactNode
rippleColor リップルの色。透明度を指定しないと重なったとき見えないので、できるだけ指定してください。 "#ffffff35" string
sparklesColorRGB Sparkles(キラキラ)の色をスペース区切りのRGBで渡します。透明度は使えません。 "255 255 255" string
opacity_level1 Sparklesが消える直前の透明度、1段階目 "0.2" string
opacity_level2 Sparklesが消える直前の透明度、2段階目 "0.1" string
sparklesMaxCount Sparkleの総量 2048 number
divProps RippleContainerがDiv要素としてレンダリングされるため、Divのプロパティは一部を除きここで渡せます。 {} Omit, | 'className' | 'onMouseDown' | 'onMouseUp' | 'onMouseLeave' | 'onTouchStart' | 'onTouchMove' | 'onTouchEnd' | 'onTouchCancel'>
onMouseDown, onMouseUp, onMouseLeave, onTouchStart, onTouchMove, onTouchEnd, onTouchCancel Ripple Effectを邪魔せずにイベントハンドラを設定できるProps。一つの関数を受け付けます。ちなみに、RippleContainerをまたButtonやaタグでラップしてそこにイベントリスナを設定するほうがおすすめです。 ()=>{} (event) => void

Ripple付きのリンクです

import { RippleContainer } from '@m_three_ui/m3ripple';

const RippleLink = ({
  href,
  children,
}) => {
  return (
    <a
      className='link'
      href={href}
    >
      <RippleContainer
        isMaterial3={true}
        className='rippleContainer'
        rippleColor="hsla(29,97%,75%,0.15)"
        sparklesColorRGB="255 255 255"
        opacity_level1="0.4"
        opacity_level2="0.1"
      >
        <div className='desc'>{children}</div>
      </RippleContainer>
    </a>
  );
};
.rippleContainer {
  width: fit-content;
  color: #f7ca9a;
  background: #5c4b39;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 9999px;
  padding: 12px 24px;

  /* Workaround(m3ripple's css modules is not working) */
  overflow: hidden;
  position: relative;
  z-index: 0;

  & :global(.ripple) {
    position: absolute;
    border-radius: 100%;
    z-index: -1;
    transform: translateX(-50%) translateY(-50%);
    pointer-events: none;
    display: flex;
    align-items: center;
    justify-content: center;
    user-select: none;
  }
  & :global(.sparkles_canvas) {
    position: absolute;
    user-select: none;
  }
}

a {
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}

現状のバージョンのバグ

ライブラリで当てているCSSがうまく機能しないことがあります(特にSPA)。
回避策としては、このCSSをRippleContainerに対して当ててください。
:global() に対応していない環境の場合は:global()を外して.rippleだけにするなどしてください。

.RippleContainer {
  /* Workaround(m3ripple's css modules is not working) */
  overflow: hidden;
  position: relative;
  z-index: 0;

  & :global(.ripple) {
    position: absolute;
    border-radius: 100%;
    z-index: -1;
    transform: translateX(-50%) translateY(-50%);
    pointer-events: none;
    display: flex;
    align-items: center;
    justify-content: center;
    user-select: none;
  }
  & :global(.sparkles_canvas) {
    position: absolute;
    user-select: none;
  }
}

おわりに

「いかがでしたか?」 は終わりの合図じゃないの? というのは置いといて、いかがでしたか?
すごく頑張って作ったので、記事も長くなってしまいました。熱量が伝わっていたら幸いです。

Reactライブラリを作るのも、NPMに公開するのも今回が初めてで、せっかくなら色々やってやろうということで、同時にGitHub Actionsにも入門してProvenanceをつけてみたり、なかなか楽しかったです。

なにかご不明点などがあればぜひコメントをお願いします。
Pull RequestsやIssueもぜひよろしくお願いします!

Discussion