はじめに
ReactでSVGを使ってお絵描きした要素をクリックして、要素を動かしたい要件があります。
先日下記の記事で、プロパティ経由でサイズを変更するというのをやりましたが、カクカク動くのもだるいし、手作り感がすごいので、今回はライブラリを使います。
使うライブラリは、framer-motion。正式名称が、framerなのか、motionなのか、framer-motionなのか、実際よくわかりません。すみません。ただ、インストールするのは、npm install framer-motion
なので、ここではframer-motionと呼びます。
ゴール
こんなものを作りました。
Elm1のスイッチをクリックすると、Elm2、Elm3、Elm4、Elm5が動くよという単純なサンプルです。
動作確認用のページはこちらに置きました。
ソースはgithubのこちら。
解説
Elm1: スイッチ
Elm1は、クリックされたら、呼び出し元へクリックされた旨を通知するボタンです。
これはよくあるパターンで、呼び出し元からプロパティのonClick
へ関数を渡して、クリックされたら下位はそれを実行するというシンプルなものなので割愛します。framer-motionも使ってません。
interface MyElm1Props {
onClick: (e: React.MouseEvent<SVGElement>) => void
}
Elm2: 即時連動
Elm2は、Elm1がクリックされたらすぐ発火する即時連動型です。ぶっちゃけこれだけあればいいかもしれません。
単純ですが、framer-motionの説明を少し濃いめで書きます。
import { useEffect } from "react";
import { useAnimate } from "framer-motion"; // <1> import
const stableColor: string = "#A28B55";
const changedColor: string = "#ff0";
interface MyElm2Pros {
isSwitchOn: boolean;
}
export function MyElm2({
isSwitchOn
}: MyElm2Pros) {
const [scope, animate] = useAnimate(); // <2> useAnimate
useEffect(() => { // <3>useEffectでSwitchON/OFFで動作
if (isSwitchOn) {
// 点灯
animate("rect", {fill: changedColor}, {duration: 1});
} else {
// 消灯
animate("rect", {fill: stableColor}, {duration: 1});
}
}, [isSwitchOn]);
return (
<>
<g
ref={scope}
>
<rect
x={0}
y={0}
width={150}
height={30}
rx={8}
ry={8}
fill={stableColor}
/>
<text
x={28}
y={21}
fill={"#000"}
>Elm2: {isSwitchOn? "ON": "OFF"}</text>
<text
x={160}
y={21}
fill={"#000"}
>スイッチと即時連動</text>
</g>
</>
)
}
<1>で、<2>で使うuseAnimate
をimport。
<2>のuseAnimate
フックの戻り値は、1項目目でアニメーションを設定する要素のスコープ。returnの中にある<g>
要素にref={scope}
と設定しています。2項目目は、それを操るanimate
関数。キモです。
animate
関数は、<3>のuseEffect
でisSwitchOn
が変更されたタイミングで呼び出されます。
animate()
の第一引数は、scope内の動かす対象の要素。ここでは"rect"としていますが、cssのようなフィルターが使えます。
第二引数は、animateした後の最終系の属性。framer-motionはhtmlやsvgのタグの属性値を徐々に変化させるのですが、その属性とゴール値をここで設定しています。
第三引数は、animateの動作に関するオプション、変更の仕方です。duration
は変化にかかる秒数です。
つまり animate("rect", {fill: changedColor}, {duration: 1});
の
場合は、対象はscopeの中の"rect"で、最終系はfillの値をchangedColor
(=#ff0)へ、変更の仕方はduration
(変化にかかる時間)を1秒間で、という指示。
なお初期値は、JSXで、<rect fill="#A28B55" />
などと指定しておけばOK。
私の場合は、1つの要素に対して点灯と消灯の2つの変化を、スイッチONかOFFかという状況によって使い分けているということになります。
ちなみにuseAnimate()
以外にも
framer-motionの公式HPには、下記のようなサンプルがたくさんあります。
<motion.div
className="box"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5, duration: 0.8 }}
/>
これは、
-
motion.div
によって、対象はこのdiv - 変更前後の値は、初期値
{opacity:0, scale:0.5}
から、最終系{opacity:1, scale:1}
へ - 変更の仕方は、0.5秒後に開始(delay)し、0.8秒間かけて(duration)、変化する
という意味です。
useAnimate
と書き方はちょっと違いますが、内容は同じです。
こちらの方が、処理っぽくなくてJSXの中に直接かけるので便利ですが、複雑な処理(例えばif文や状況によって動かし方を変えたいとか)になると、JSXではかえって煩雑です。なので、この記事では、useAction
を使った書き方で統一します。
公式HPには、「useAnimate()
vs <motion.div />
」というセクションがあり、複雑な処理とか、一時的に止めたり再生したりしたいとか、変更値を直接いじりたいとかの場合は、複雑なことができるuseAnimate()
を使えばいいよと書いてあります。私は先にuseAnimate()
を覚えたら、<motion.div />
の書き方がすんなりわかったので、まずはuseAnimate()
を使っていればいいと思います。
Elm3: バネのような動き
アニメーションするというとよくあるバネの動きも、メニューの開け閉めのような形で使いたくなりそうだったので、メインスイッチに連動する形で作ってみました。
import { useEffect } from "react";
import { useAnimate } from "framer-motion";
interface MyElm3Pros {
isSwitchOn: boolean;
}
export function MyElm3({
isSwitchOn
}: MyElm3Pros) {
const [scope, animate] = useAnimate();
useEffect(() => {
if (isSwitchOn) {
// 伸ばす
animate(
"rect",
{height: 100},
{
type: "spring",
stiffness: 700,
damping: 10
}
);
} else {
// 縮める
animate(
"rect",
{height: 30},
{
type: "spring",
stiffness: 1000,
damping: 30
}
);
}
}, [isSwitchOn]);
return (
<>
<g
ref={scope}
>
<rect
x={0}
y={0}
width={150}
height={30}
rx={8}
ry={8}
fill={"#86AB89"}
/>
<text
x={28}
y={21}
fill={"#000"}
>Elm3: {isSwitchOn? "open": "close"}</text>
<text
x={160}
y={21}
fill={"#000"}
>バネで伸縮</text>
</g>
</>
)
}
Elm2で、useAnimate()
周辺をだいぶ説明したので、その話は割愛。大まかには同じです。違いは、animate()
の引数です。
伸ばすときのコードを再掲します。
// 伸ばす
animate(
"rect",
{height: 100},
{
type: "spring",
stiffness: 700,
damping: 10
}
);
animate()
の、対象が"rect"で、最終系は"height=100"、ここまではElm2と同じです。変更の仕方で、type="spring"
と指定しています。
stiffness
はバネの堅さ。堅いバネを伸ばした後に戻るスピード、縮めた後に伸びるスピードを想像すると、堅い方が速いですね。つまり、バネの伸び縮みのスピードだと思うとよいです。
damping
は摩擦。この値が小さいと、いつまでもフニャフニャ動いている感じ。大きいと、あまりバネっぽくなくふわっと終わります。ふんわり変化させたいときに、この値を大きめにして使えばいいかなと。
動作確認用のElm3では、広げるときはdamping小さめ、縮めるときは大きめでやってます。
springはこの2つのパラメータでいいでしょう。もっと複雑に変化させる方法もたくさんありますが、現実的に使うところだけのいいとこどりで行きます。
他のtypeは、Tween(細かな調整ができるやつ・デフォルト)、Inertia(慣性)があります。スピード・加速の調整は「イージング」という分野です。詳しく知りたい方は、下記をご覧ください。(大体の場合、いらないと私は思う。でも面白い。)
Elm4: 線をつなげる
できるとかっこいい感じのやつです。線を描くやつ。英語の筆記体とかできたらかっこいいですね。(自由曲線を定義するのが大変だけど)
なんと、ここまでの知識があれば、簡単です。
import { useEffect } from "react";
import { useAnimate } from "framer-motion";
interface MyElm4Pros {
isSwitchOn: boolean;
}
export function MyElm4({
isSwitchOn
}: MyElm4Pros) {
const [scope, animate] = useAnimate();
useEffect(() => {
if (isSwitchOn) {
// 描く
animate("path",
{ pathLength: 1 },
{ duration: 2.0 }
);
} else {
// 消す
animate("path",
{ pathLength: 0 },
{ duration: 0.5 }
);
}
}, [isSwitchOn]);
return (
<>
<g
ref={scope}
>
<path
d={
"M 20 0 " +
"C 20 30, 10 130, -10 110 " +
"C -20 100, 0 70, 20 90 " +
"C 40 110, -40 205, -10 235 " +
"C 0 245, 30 235, 50 235"
}
stroke="#fa6"
strokeWidth={2}
fill="none"
pathLength="0"
id="cablePath"
/>
<text
x={-20}
y={50}
fill={"#000"}
>
<textPath
href="#cablePath"
startOffset="0.5"
textLength="150"
>
Elm4:伸びる線
</textPath>
</text>
</g>
</>
)
}
まず、SVG要素の<path />
で自由曲線を定義します。これはプログラミングというか、数学です。パラメータd
で3次ベジエ曲線を書いてますが、この説明は割愛!
ご自分で調べたい方は、3次ベジエ、コントロールポイント(CP)、などのキーワードでググるとよいかもしれません。Illustratorで書いてSVG出力もできるかも。
ちなみに私は、紙に始終点とコントロールポイントを描いて座標を仮決めした後、<path d=~ />
に数字を入れて、ブラウザで見て、微調整してこの線を描きました。泥臭すぎる。
さて、話を戻して、framer-motionで変化させる値は、pathLength
です。pathLength
は、SVGの<path />
要素のパラメータの1つで、0~1で設定した値を0%~100%とみて、始点から何%まで線を描くかというものです。
つまり、<path pathLength="0.0" />
を<path pathLength="1.0" />
へ連続的に変更させることで、線が描かれているようなアニメーションになります。
そう思ってコードを見てみると、初期はJSXで<path pathLength="0" />
となっていて、下記です。
useEffect(() => {
if (isSwitchOn) {
// 描く
animate("path",
{ pathLength: 1 },
{ duration: 2.0 }
);
} else {
// 消す
animate("path",
{ pathLength: 0 },
{ duration: 0.5 }
);
}
}, [isSwitchOn]);
pathLength
を変更しているだけです。描くときと消すときで、duration
(かかる時間)を変えてます。
それだけ。
ちなみに線に沿わせた文字列「Elm4:伸びる線」で使った<textPath />
は、SVG要素の話なので割愛!
Elm5: 遅延連動
Elm5は、Elm4がつながったから色が変わったかのように見えますが、全然関係ないです。Elm4のduration
(かかる時間)と、Elm5のdelay
(開始まで待つ時間)を一緒にすることで、Elm4の終わるタイミングとElm5の始まるタイミングが同じというだけです。"遅延連動"なんて書きましたが、"遅延連動っぽく見せる"です。
もうそこまで言ったら簡単だと思いますが、一応コードを。
import { useState, useEffect } from "react";
import { useAnimate } from "framer-motion";
const stableColor: string = "#A28B55";
const changedColor: string = "#fa0";
interface MyElm5Pros {
isSwitchOn: boolean;
}
export function MyElm5({
isSwitchOn
}: MyElm5Pros) {
const [scope, animate] = useAnimate();
const [textOnOff, setTextOnOff] = useState<string>("OFF");
useEffect(() => {
if (isSwitchOn) {
// 点灯
animate("rect", {fill: changedColor}, {delay: 2.0}); // 2秒後に開始
setTimeout(()=>{
setTextOnOff("ON");
}, 2.0*1000);
} else {
// 消灯
animate("rect", {fill: stableColor}, {duration: 0.5});
setTextOnOff("OFF");
}
}, [isSwitchOn]);
return (
<>
<g
ref={scope}
>
<rect
x={0}
y={0}
width={150}
height={30}
rx={8}
ry={8}
fill={stableColor}
/>
<text
x={28}
y={21}
fill={"#000"}
>Elm5: {textOnOff}</text>
<text
x={160}
y={21}
fill={"#000"}
>遅延して連動</text>
</g>
</>
)
}
animate()
のパラメータはもういいでしょう。第三パラメータの変更の仕方でdelay
を設定することで、開始タイミングを遅らせています。消すときはdelay
を設定していないので、すぐ消し始めます。
ついでに、"ON"と"OFF"という文字列を<text />
で書いているので、setTimeout()
を使って、開始タイミングと同時にsetTextOnOff()
を呼び出すように仕掛けてあります。ここも、さも連動しているかのようだけど、秒数を合わせているだけ。
ここでは簡単のために、Elm4は"2秒"で描き終わり、Elm5は"2秒後"に描き始め、textも"2秒"待って変更するのを、各々で設定していますが、本番では1か所で定義してパラメータで渡すべきです。2秒でなく3秒に変更!というときに変更箇所が多いとめんどくさいしミスるので。
それを"連動"と呼ぶかというと、やっぱり連動ではないですね。同じ値を設定しているだけ。
まとめ
結局、「アニメーションしたい!」という要望は、「どのプロパティ値をいくつからいくつへ変更したい!」と言い換えて、それをanimate()
関数へ渡せばいいってことでした。
Elm2のところで書いた、対象、初期値、最終系、変更の仕方を指定するんだと思えば、そんなに難しくなくできると思います!
今回は、色、サイズ等のパラメーターを1つずつ変更しただけですが、複数を同時に変更すると、より驚くようなアニメーションができます。
ではよいSVGアニメーションライフを!