1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Reactで、SVGをぬるぬる動かす

Last updated at Posted at 2024-08-18

はじめに

ReactでSVGを使ってお絵描きした要素をクリックして、要素を動かしたい要件があります。

先日下記の記事で、プロパティ経由でサイズを変更するというのをやりましたが、カクカク動くのもだるいし、手作り感がすごいので、今回はライブラリを使います。

使うライブラリは、framer-motion。正式名称が、framerなのか、motionなのか、framer-motionなのか、実際よくわかりません。すみません。ただ、インストールするのは、npm install framer-motionなので、ここではframer-motionと呼びます。

ゴール

こんなものを作りました。
Elm1のスイッチをクリックすると、Elm2、Elm3、Elm4、Elm5が動くよという単純なサンプルです。

image.png

動作確認用のページはこちらに置きました。

ソースはgithubのこちら。

解説

Elm1: スイッチ

image.png

Elm1は、クリックされたら、呼び出し元へクリックされた旨を通知するボタンです。
これはよくあるパターンで、呼び出し元からプロパティのonClickへ関数を渡して、クリックされたら下位はそれを実行するというシンプルなものなので割愛します。framer-motionも使ってません。

MyElm1.tsx(interfaceだけ)
interface MyElm1Props {
    onClick: (e: React.MouseEvent<SVGElement>) => void
}

Elm2: 即時連動

image.png

Elm2は、Elm1がクリックされたらすぐ発火する即時連動型です。ぶっちゃけこれだけあればいいかもしれません。
単純ですが、framer-motionの説明を少し濃いめで書きます。

MyElm2.tsx
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>のuseEffectisSwitchOnが変更されたタイミングで呼び出されます。

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: バネのような動き

image.png

アニメーションするというとよくあるバネの動きも、メニューの開け閉めのような形で使いたくなりそうだったので、メインスイッチに連動する形で作ってみました。

MyElm3.tsx
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()の引数です。

伸ばすときのコードを再掲します。

MyElm3.tsxのuseEffectの一部
// 伸ばす
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: 線をつなげる

image.png

できるとかっこいい感じのやつです。線を描くやつ。英語の筆記体とかできたらかっこいいですね。(自由曲線を定義するのが大変だけど)

なんと、ここまでの知識があれば、簡単です。

MyElm4.tsx
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" />となっていて、下記です。

MyElm4.tsx(useEffectの部分)
    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の始まるタイミングが同じというだけです。"遅延連動"なんて書きましたが、"遅延連動っぽく見せる"です。

もうそこまで言ったら簡単だと思いますが、一応コードを。

MyElm5.tsx
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アニメーションライフを!

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?