Android 12みたいなリップルエフェクトをWebで実現するReact用ライブラリを作りました。
ざっくりまとめると...
Android 12以降に導入された、キラキラと光る演出付きのリップルエフェクトをWeb上で再現するReact用ライブラリを作成しました。
ドキュメント
npm GitHubリポジトリ実際に試してみる
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
〜に載っています)
私は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が奥深いものだと気づきます。
ここでリップルエフェクトとはどんなものなのか、整理してみましょう。
-
ユーザがタップ・クリックを開始する(
mousedown
/touchstart
イベントが発火) -
円(今後はこれをRippleと呼びます)が出現する
サイズは0からのスタートとは限らない -
Rippleが大きくなる
タップしたところの座標から、タップした要素の最も遠い角の2倍の大きさ(width
)まで広がる -
Ripple Effectが最大まで広がってもなお、ユーザがタップまたはクリックを続けている
Rippleは消さず、最大まで広がったRippleを残し続ける -
ユーザがタップ・クリックを終える(
mouseup
/touchend
イベントが発火) or ユーザの指が何らかの理由で該当の要素から離れる(touchcancel
イベントが発火) or マウスがクリックされたままの状態で要素から離れる(mouseleave
イベントが発火)-
Rippleが最大まで広がり終えている場合
Rippleをフェードアウトさせて消す -
Rippleが最大まで広がっていない場合
Rippleが最大まで広がるのを待ってからフェードアウトさせて消す。
-
これがRipple Effectです。 これのどれかが欠けると不完全だと、私は思っています。
というか何かまだ足りないものがあるかもしれません。
試したほうが実感できると思うので、ぜひ試してみてください。
処理のシーケンス以外の重要事項
Ripple Effectの特徴はこれだけではありません。
アニメーションを伴うエフェクトである以上、イージングと時間が重要になってきます。
イージング
イージングとは、アニメーションの進行に緩急を与える方法です。
アニメーションに緩急をつけることで、いきいきとした、現実の力学と近い動きにすることができます。
すべてのアニメーションにイージングが必要だというわけではなく、ないほうがいい場合が多くあることに気をつける必要はありますが、リップルエフェクトにはイージングが必要です。あったほうが気持ちがいいです。
そこでイージングをつけるわけですが、リップルエフェクトはユーザからの入力を受け取ったと、ユーザに伝えるためのものです。
そのため、円の拡大・縮小のアニメーションは、タップしてすぐに一気に加速したほうが気持ちがいいでしょう。その後は負の向きに加速度のかかったようなイージングを設定します。
cubic-bezier(0, 0.49, 0, 1)
グラフはここから確認できます:
そのほかにも、フェードアウト・フェードインにも適宜イージングを追加します。時間
もともと存在しなかった要素が、アニメーションを伴って登場するときには、どんなアニメーションをつけると良いと思いますか?
🤔 「もともと存在しなかったわけだから、大きさ0から拡大とか、画面外の
x: (負の数)
から移動とかかな?」
確かに!
でも、案外そうとも限りません
これは映像制作においてもよく意識されていることなのですが、人間の脳は映像を補完します。
つまり、足りない部分を補って見せてくれるのです。だから実は、画面外でなくとも、大きさ0でなくてもいいんです。
※ ゆっくり・勢いがついていない登場アニメーションで表示させる場合は、補完してくれないのでお気をつけて!
🤨 「...でも、なんでそんなことをするの?」
例えば、大きさ0で登場し、200まで拡大されるRippleと、大きさ70で登場し、200まで拡大されるRippleがあったとしましょう。脳が補完するので、これはどちらも違和感なく見ることができます。
ただ、後者のほうがレスポンス良く感じます。 クリックした直後に、絶対に70の大きさで表示されることになるので、もたつきを感じづらくなり、より気持ちのいいRipple Effectにすることができるのです。
だから今回は、私はRippleを最大の
Reactで実装する
Ripple Effect について詳しくなったので、早速Reactで実装していきました。
Rippleの拡大縮小は Web Animation API を使ってアニメーションさせることにします。
理由はライブラリに依存する必要もなく使えてわかりやすく、アニメーションの終了を finished
プロパティ で待機することができるからです。
#Ripple Effectを考えるで書いた通り、アニメーションが終了しているかを確認する場面があるため、このプロパティは重要です。
”Reactっぽい” 実装
あまりReactをよく知っているわけではないものの、Reactは与えられた状態からUIを構築するというイメージがあったため、Ripple Effectを効かせる対象のコンポーネントとして <RippleContainer />
を用意し、現在表示中のRippleを状態として持たせることにしました。
こうすることで、Stateとして持っている ripples
配列の RippleObj
オブジェクトを追加したり削除したりすることでRippleが操作できます。
上のアニメーションのコードを見てもらうとわかりますが、<Ripple />
コンポーネントの useEffect()
で、コンポーネントが表示された瞬間アニメーションを発動させているのはこのためです。
Rippleを消す前にはフェードアウト アニメーションの処理が必要になるので、tobeDeleted
というプロパティを用意し、 tobeDeleted
が true
になった場合に <Ripple />
コンポーネントでフェードアウト アニメーションを行い、完了したら親要素( <RippleContainer />
)の関数を子要素( <Ripple />
)から呼び出して、親要素の状態である ripples
から RippleObj
を削除しています。
簡単なシーケンス
- クリック/タップ等で
ripplePerform()
が呼ばれる -
RippleObj
の配列をStateとして持っているので、ripplePerform()
からsetRipples()
を呼び出してrippleを追加する。 - rippleのアニメーションが終わり、かつクリックやタップが続けられていない場合、子要素でフェードアウト アニメーションを行い、完了後に
setRipples()
で状態ripples
からRippleを削除、画面からRippleが消える。
Sparklesの処理と、Rippleのサイズ計算など
キラキラ(今後は Sparkles
と呼びます)が今回の核心なのに、まだ出てきていなかったですね。sparkles.tsに詳しい処理は載っているので確認していただきたいのですが、ポイントをいくつか紹介します。
SparklesをCanvasに描画
Sparklesは各 <Ripple />
コンポーネントの中にある <SparkleCanvas />
の一枚のCanvasに描画しています。
これは試行錯誤の結果で、 <div>
タグを大量に配置してみたり、Canvasに描画しつつも、(Canvasでのアニメーションの経験がなかったために)毎フレームCanvasを再生成してみたりしていましたが、前者はFirefoxではカクカクで厳しく、後者はだいぶマシになったものの気分的にはまだやれるなという感じで、結果的に頑張って一枚のCanvasの中で requestAnimationFrame()
を使ってアニメーションさせるのに落ち着きました。
計算処理
描画タイミングにおけるRippleの半径を半径とする円の形に散らばって点が打たれるように実装しました。
円は成分表示すると
ちなみに乱数を足すだけだと外方向にしか広がらないので、足すだけでなく引いてもいます。
Ripple関係の計算
三平方の定理で最大のwidthを算出
ripple.tsで詳しく見ることができますが、リップルの大きさを計算するときは、最大までRippleが大きくなったとき、絶対にRippleの端っこが見えてしまわないように、クリック地点から最も遠い角までの距離の2倍(widthは直径なので)をRippleの最大の大きさに指定してあげなければいけません。
そこで三平方の定理を使います。
<RippleContainer />
基準に。
offsetを常に また、クリックされた座標を始点にRippleをアニメーションさせるために offset
を使いたいのですが、 <RippleContainer />
にイベントハンドラを登録していても、その子要素がクリックされた場合、 event.offsetX
やevent.offsetY
は子要素基準の offset
となるため、リップルがうまく配置できません。
そのため、常に親要素( <RippleContainer />
)が基準になるように、親要素の位置を getBoundingClientRect()
で取得し、 event.clientX
を引くという方法を使っています。
ハマったところ
useEffect()
が2回呼ばれて、リップル消去の挙動が変
<RippleContainer />
のStateとしてRippleを管理している都合から、Rippleを追加したり削除したりする中で生じる再レンダーで、ある関数( deleteRipple()
)が何度も再定義され、その関数を渡している <Ripple />
のuseEffect()
が何度も呼ばれて挙動がおかしくなりました。
何度も再定義されていた関数に useCallback
を使って再レンダー時に再定義させないようにしたら正常に動作するようになりましたが、 useCallback
はパフォーマンスの最適化のみに使用されるべきだとドキュメントに書かれていたので、加えて useEffect()
が複数回呼ばれても処理は一回のみ実行されるように Ref
を使って実装しました。
具体的には、削除処理を行うかを指定する tobeDeleted
が true
のときに何度も再レンダーされ useEffect()
が呼ばれると、何度もRippleの削除処理が行われて意図しない挙動になったため、削除処理は一回のみ実行されるように Ref
で管理することにしました。
タッチデバイスで、スクロール時にちょっと触れただけでもリップルが発動して邪魔
touchstart
でRipple表示、touchend
でRipple消去という実装をシンプルにしただけでは、スクロール時に要素に一瞬触れただけでもRipple Effectが発動してしまいました。
これだと、タップしたつもりはないのにタップされた際の効果が発動していることになるので、ユーザは恐怖です。
「間違えてなにか危ないボタンを無意識に押してたのでは...?!」となってしまいます。
これに以下の手順で対処しました。
touchstart
が呼ばれる-
0.1秒待つ
この0.1秒の間にtouchmove
が呼ばれた場合、isScroll
をtrue
する isScroll === false
の場合だけリップルエフェクトを発動させるisScroll
をfalseにする
タッチデバイスで、リップルが二重発動
タッチデバイスでは touchstart
-> touchend
-> mousedown
-> mouseup
と、touch関係だけでなくmouse関係のイベントまで呼ばれてしまいます。
Ripple Effectは touchstart
/ mousedown
どちらでも始まるので、タッチデバイスでは二重にRippleが表示されてしまいます。
これを、touchstart
が呼ばれてから1秒間は mousedown
によるRipple Effectの発動を無効化する処理で対応しました。
いかがでしたか?
これでだいたい、Ripple Effectの奥深さと、私がこれをライブラリにして配布するべきだと思った理由はおわかりいただけたでしょうか...?
とにかくこれを、特にRipple Effectがメインのプロジェクトではない場合、自分で実装するのはとにかく辛いです。だから、せっかく実装したならライブラリにしてしまおう、という発想でライブラリ化してみました。
めちゃくちゃ頑張って作ったサイト
npmからインストールできます。
GitHubはこちら。
使い方
インポートして、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