jackmiwamiwa devblog

フロントエンドをメインに行ったことをまとめていくサイトになります。

React + GSAP を使用したアニメーションを作成する

制作物

https://react-gsap-animation.netlify.app/

f:id:jackswim3411:20211122183118p:plain

対応したこと

  • 左上の「Add Circle」を押したら、アニメーションのコンポーネントが表示される
  • 要素のアニメーションはクリックされた分実行される
  • アニメーションのコンポーネントについて
    • 中央のアニメーションの塗りつぶし
    • 要素の色・アニメーションを行う箇所はランダム
    • 小さい円のアニメーションが以下のようにアニメーション
      1. 楕円が一つずつ下から移動
      2. 1と同じタイミングで楕円の拡大
      3. 楕円の丸みを正円に変更
      4. 楕円の縮小からのサイズ最小値

今回実装したコード

App.tsx

export const App = () => {
  const [count, setCount] = useState<number>(0)
  const addCircle = () => {
    setCount(count + 1)
  }
  return (
    <div className="App">
      <button type="button" onClick={addCircle}>
        Add Circle
      </button>
      {Array(count)
        .fill(0)
        .map((_, index) => (
          // 今回アニメーションを行うコンポーネント
          <SvgCircle key={index} />
        ))}
    </div>
  )
}

コンポーネント

import React, {
  createElement,
  VFC,
  useState,
  useEffect,
  useRef,
  Fragment,
  forwardRef,
} from "react"
import { gsap, Power2 } from "gsap"
import { setup, styled, keyframes } from "goober"

setup(createElement)

// 円周が100pxになるように逆算した時の半径のサイズ
const CIRCLE_RADIUS = 15.9154943092
// 円の中心の値(半径の倍の値)
const CIRCLE_POSITION_CENTER = 31.8309886184
// svgのサイズ
const SVG_ELEM_SIZE = 320

/**
 * @see https://greensock.com/forums/topic/14787-transform-in-vh-units/?tab=comments#comment-79432
 */
const vw = (coef: number) => (window.innerWidth - SVG_ELEM_SIZE) * (coef / 100)
const vh = (coef: number) =>
  (window.innerHeight - SVG_ELEM_SIZE) * (coef / 100) - SVG_ELEM_SIZE

export const SvgCircle: VFC = () => {
  const refWrapper = useRef(null)
  const q = gsap.utils.selector(refWrapper)

  const [color, setColor] = useState<string>()
  const [xPosition, setXPosition] = useState<number>(0)
  const [yPosition, setYPosition] = useState<number>(0)
  const [isAnimeRunning, setIsAnimeRunning] = useState<boolean>(true)

  useEffect(() => {
    const colorCode = Math.floor(Math.random() * 16777215).toString(16)
    setColor(`#${`000000${colorCode}`.slice(-6)}`)
    setXPosition(Math.floor(Math.random() * 100))
    setYPosition(Math.floor(Math.random() * -100))
  }, [])

  useEffect(() => {
    const refWrapperCurrent = refWrapper.current
    gsap.set(refWrapperCurrent, {
      y: 0,
      x() {
        return vw(xPosition)
      },
    })
    gsap.to(refWrapperCurrent, {
      keyframes: [
        {
          duration: 0.7,
          ease: Power2.easeOut,
          y() {
            return vh(yPosition)
          },
        },
        {
          duration: 0.5,
          delay: 1.4,
          y: 0,
          ease: Power2.easeIn,
        },
      ],
      onComplete() {
        setIsAnimeRunning(false)
      },
    })
  }, [xPosition, yPosition])

  useEffect(() => {
    gsap.set(q(".smallCircle"), {
      fill: color,
      scale: 0,
      y: 30,
    })
    gsap.to(q(".smallCircle"), {
      keyframes: [
        {
          duration: 0.3,
          scaleX: 1,
          scaleY: 1.5,
          transformOrigin: "center",
          y: 0,
          delay: 0.5,
          stagger: 0.1,
        },
        { duration: 0.4, scale: 1 },
        { duration: 0.4, scale: 0, delay: 0.3 },
      ],
    })
  }, [color])
  return (
    <Fragment>
      {isAnimeRunning && (
        <SvgWrapper1 ref={refWrapper}>
          <SvgWrapper2>
            <svg
              xmlns="http://www.w3.org/2000/svg"
              width={SVG_ELEM_SIZE}
              height={SVG_ELEM_SIZE}
              viewBox={`0 0 ${SVG_ELEM_SIZE} ${SVG_ELEM_SIZE}`}
              color={color}
            >
              <SvgCircleLarge
                color={color}
                cx="170"
                cy="150"
                r={CIRCLE_RADIUS}
              />
              <SvgCircleLargeLine color={color} cx="140" cy="140" r="65" />
              <circle className="smallCircle" cx="220" cy="50" r="10" />
              <circle className="smallCircle" cx="150" cy="25" r="10" />
              <circle className="smallCircle" cx="80" cy="40" r="10" />
              <circle className="smallCircle" cx="30" cy="85" r="10" />
            </svg>
          </SvgWrapper2>
        </SvgWrapper1>
      )}
    </Fragment>
  )
}

/**
 * 扇のアニメーションについて
 * @see https://ksk-soft.com/2014/08/06/svg-pie-graph/
 */
const sdaAnime = keyframes`
  0% {
    stroke-dasharray: 0, 100;
  }
  100% {
    stroke-dasharray: 100, 0;
  }
`

const animeRotate = keyframes`
  0% {
    transform: rotate(45deg);
  }
  100% {
    transform: rotate(-45deg);
  }
`
/**
 * easingの種類
 * @see https://easings.net/ja#
 */
const easeInQuart = "cubic-bezier(0.5, 0, 0.75, 0)"
const easeInOutCubic = "cubic-bezier(0.65, 0, 0.35, 1)"

const SvgWrapper1 = styled("div", forwardRef)`
  bottom: -${SVG_ELEM_SIZE}px;
  position: absolute;
`

const SvgWrapper2 = styled("div")`
  animation: ${animeRotate} 2s 0.2s ${easeInQuart} both;
`

const SvgCircleLarge = styled("circle")`
  animation: 1.5s 0.3s ${sdaAnime} ${easeInOutCubic} both;
  fill: transparent;
  stroke: ${({ color }) => color};
  stroke-dashoffset: 50;
  stroke-width: ${CIRCLE_POSITION_CENTER};
  transform: scale(-2, 2);
  transform-origin: center;
`

const SvgCircleLargeLine = styled("circle")`
  fill: transparent;
  stroke: ${({ color }) => color};
  stroke-width: 5;
`

使用したパッケージ

  • "goober": "^2.0.41"
  • "gsap": "^3.7.1"
  • "react": "^17.0.0"
  • "typescript": "^4.3.2"
  • "vite": "^2.5.1"

実装手順

1. UIの実装

今回作成したマークアップについては以下になります。

<SvgWrapper1 ref={refWrapper}>
  <SvgWrapper2>
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width={SVG_ELEM_SIZE}
      height={SVG_ELEM_SIZE}
      viewBox={`0 0 ${SVG_ELEM_SIZE} ${SVG_ELEM_SIZE}`}
      color={color}
    >
      <SvgCircleLarge
        color={color}
        cx="170"
        cy="150"
        r={CIRCLE_RADIUS}
      />
      <SvgCircleLargeLine color={color} cx="140" cy="140" r="65" />
      <circle className="smallCircle" cx="220" cy="50" r="10" />
      <circle className="smallCircle" cx="150" cy="25" r="10" />
      <circle className="smallCircle" cx="80" cy="40" r="10" />
      <circle className="smallCircle" cx="30" cy="85" r="10" />
    </svg>
  </SvgWrapper2>
</SvgWrapper1>

SvgWrapper1

要素の出現アニメーションを行うための要素。

SvgWrapper2

要素を右から左に回転させるための要素。

svg

今回アニメーションを行うためのSVG全体の要素

SvgCircleLarge

SVGの丸部分のアニメーションを行うための要素

SvgCircleLargeLine

SVGの輪郭部分。中のものをアニメーションさせるため、輪郭用に別のcircleの要素を指定。

circle

小さい4つの円の要素。

2. css・gsapで行う箇所の切り分け・実装

cssでアニメーションできそうな箇所とgsapでアニメーションで切り分けた箇所について、

  • cssでのアニメーション
    • 単一のアニメーションの場合(opacityが0から1に変更するなど)
  • gsapでのアニメーション
    • アニメーションが複数ある場合(opacityが0から1に変更した後に、要素を移動したいなど)
    • cssだけでの調整が難しいとき(初めの0.3sで出現・1.7s止まった後に0.3sかけてなど)

実際に今回行った箇所については以下になります。 - cssでのアニメーション - 中の円の塗りつぶしアニメーション - 要素全体を右側から左側に回転 - gsapでのアニメーション - 要素全体の上昇アニメーション - 小さい丸の方の出現タイミング・要素のアニメーション

3. ボタンをクリックした時に要素が出現するようにする

ボタンを押した時にアニメーションさせようと思っているため、以下のことを行いました。

  1. ボタンをクリック
  2. クリックした時に要素を追加

詰まった部分について

1. storybook接続時にstorybookでエラーになる

こちらについて、以下のようなコードの場合、

import React, { VFC } from "react"
import { styled } from "goober"

export const SvgCircle: VFC = () => <SvgCircleWrapper>text</SvgCircleWrapper>

const SvgCircleWrapper = styled("svg")`
  background: #f0f;
  display: block;
  height: 10px;
  width: 10px;
`

f:id:jackswim3411:20211122175355p:plain

のエラーが出現する。対応策については以下のコードを追加

import React, { createElement, VFC } from "react"
import { setup, styled } from "goober"

setup(createElement)

全体のコードについては以下のように変更すると問題なく表示されます。

import React, { createElement, VFC } from "react"
import { setup, styled } from "goober"

setup(createElement)

export const SvgCircle: VFC = () => <SvgCircleWrapper>text</SvgCircleWrapper>

const SvgCircleWrapper = styled("svg")`
  background: #f0f;
  display: block;
  height: 10px;
  width: 10px;
`

f:id:jackswim3411:20211123162235p:plain

dev.to

goober.js.org

2. カラーコードがうまく取得できない

https://q-az.net/random-color-code/

を参考に その1 有名な方法 のような書き方を行っていたところ、 012345の時に12345と変換してしまいエラーになってしまったため、 その6 for なしのものに修正しました。

// その1 有名な方法
var randomColor = "#" + Math.floor(Math.random() * 16777215).toString(16);
// その6 for なし
var color = (Math.random() * 0xFFFFFF | 0).toString(16);
var randomColor = "#" + ("000000" + color).slice(-6);

3. アニメーション終了しても要素が残ってしまっている

コンポーネント部分にて、

const Hoge = () => {
  return (
    <div>Hoge</div>
  )
}

のように記載していると、アニメーションが終わった後にコンポーネントがDOM上に残ってしまい、後々のアニメーションの処理が重くなってしまうため、以下のように修正してアニメーションが終わったタイミングでDOMの削除を行う。

const Hoge = () => {
  return (
    <>
      { isHoge && ( // アニメーションが終わったら、isHogeをfalseにして要素を非表示にする
        <div>Hoge</div>
      )}
    </>
    <div>Hoge</div>
  )
}