🔰

Reactのアニメーションに関する困ったこと

2023/03/07に公開

Reactで困ったこと

Reactでアニメーションを実装しようとすると、ときどき困ることがある。
特にモーダルやポップアップなどの表示・非表示をアニメーションで表現する場合、Stateで表示・非表示を管理すると、アニメーションがうまく動かないことがある。

次のようなコードを考えてみる。

type Props = {
  show: boolean;
  children: React.ReactNode;
};

const Component = ({show, children}: Props) => {

  return show ? (
    <div
      style={{
        // PopUpのアニメーションを行うコード
      }}
    >
      {children}
    </div>
  ) : null;
}

このコードは残念ながらうまく動かない。
show = trueの時は、アニメーションが実行されるが、show = falseの時は、アニメーションが実行される前に、nullがコンポーネントから返されることになるので、アニメーションが実行されない。

解決策

1. そもそもコンポーネントを消さなければ良い。

まず、コンポーネントを消すのではなく、opacityなどを用いて透明化し非表示にすることで、アニメーションを実現することができる。ただし、この場合はそのコンポーネント自体は消えないことになるので、裏側にコンポーネントが残り続けてしまう。そのため、パフォーマンス的にあまり良くない。

2. アニメーションの変更をStateで管理するのではなく、Refで管理する。

Refで管理すれば、アニメーションが終わる前にref.currentを変更しても再レンダリングは走らないので、アニメーションは実行される。しかし今度は開始時のアニメーションが動かないことになる。そのため、ForceRenderをする必要が出てくるか、Refと別のStateの2つの値を管理することになる。これだとコードが煩雑になってしまう。

3. ライブラリやその他のものを使う。

ということで、この記事ではその他の方法を使ってみて、どのライブラリが使いやすいかなどを調べてみる。

Animation関連のライブラリ

    1. Normal CSS
    1. React Transition Group
    1. React Spring
    1. Framer Motion

の4つについて、簡単な表示/非表示を切り替えるコードを書いてみて、どれが使いやすいかを調べてみる。
ちなみに、実験環境として、Next.js + TypeScript + emotionを使用している。

1. Normal CSS

emotionCSSのみを使って、表示/非表示を切り替えるコードを書いてみたところ、思ったよりも良い実装ができた。
内部で別のStateとして、表示/非表示を管理し、表示時はすぐさま、非表示時はonAnimationEndで非表示にするようにしている。

  <div
    onAnimationEnd={(e) => onAnimationEnd(e)}
    css={[animationcss(show), elementcss]}
    id={animationId}
  >
    {children}
  </div>

ここで注意しなければいけないのが、バブリングの存在である。例えば、popUpの中でonAnimationEndを用いたアニメーションを行った時に、バブリングが起こりここのonAnimationEndも実行されてしまう。そのため、ランダムなanimationIdを生成し付与することでアニメーションのターゲットが自分自身でないものは実行しないようにする必要があることに注意が必要。

  const onAnimationEnd = useCallback(
    (e: React.AnimationEvent<HTMLDivElement>) => {
      if (!(e.target instanceof HTMLElement)) return;
      if (e.target.id !== animationId) return;

      //省略
    }, [animationId, ...]
  );

こうすることで、思ったよりも普通のCSSでの実装は簡単にできることがわかった。
上で述べた一部の注意点があることだけを心に留めておく必要がある。

と思ったが、他の人から別にe.targete.currenttargetが同じかを判断すればいいのではと言われ、確かにとなったのでここに追記しておく。

2. React Transition Group

React Transition Groupは、Reactのtransitionを簡単に実装できるReactのAdd-onライブラリである。アニメーションのライブラリというわけではなく、transitionに沿ってCSSを適用しやすいようにclassNameを付与したりすることに特化したライブラリである。

emotionを使用している都合上、classNameを変更するCSSTransitionが使いにくかったので、一般的なTransitionコンポーネントを使用して、実装を行った。

<Transition
  in={show}
  appear
  unmountOnExit
  timeout={2000}
  onEntering={() => animationCallbackFunctions?.onStartShow?.()}
  onEntered={() => animationCallbackFunctions?.onEndShow?.()}
  onExiting={() => animationCallbackFunctions?.onStartHide?.()}
  onExited={() => animationCallbackFunctions?.onEndHide?.()}
  nodeRef={nodeRef}
>
  {(state) => (
    <div ref={nodeRef} style={styles[state]}>
      {children}
      <p>{state}</p>
    </div>
  )}
</Transition>

使用する際の注意点として、appearを付与することである。これを付与しないと、初回の表示時にアニメーションが実行されない。これを付与せずに初回レンダリングのみアニメーションが実行されないという問題にかなりはまった。
そして、大事なのがunmountOnExitを付与することで、非表示時(onExitedが呼ばれた際)にコンポーネントを消すことができる。

nodeRefを指定してないと、findDomNodeが呼ばれてしまい、strict Mode下ではWarningが出るので、指定することがおすすめされているらしい。

全体的にCallback関数などがまとまっており、かなり見通しのよいコードになるとは思った。
その一方で、transitionの変更に支える手法が

  • timeoutを指定する
  • addEndListenerを指定する

のどちらかであるらしく、任意のアニメーションに対応するためには1で述べたようにonTransitionEndなどのイベント関数を使用する必要があり、これはバブリングなどを考慮する必要がでてくることに注意する必要がある。

また、React Transition Groupという名前だけあって、transitionに特化しているので、animationだと使えないことに注意する必要がある。

3. React Spring

React Springは、Reactのアニメーションライブラリとして知られている。
React Spring自体の機能に非表示の際にアニメーションを走らせてからコンポーネントをunmountするような仕組みは存在しないので、自力で作る必要がある。そのため、NormalCSSと同じようにuseEffectなどを用いて自前でStateを管理することになる。つまり、React Springを用いて、このような問題に対処することはかなり難しいと感じた。
どちらかというと、「アニメーション」の複雑な動きを実装するためのライブラリとしての役割が強いと思う。

const [visible, setVisible] = useState(show);

const [springs, api] = useSpring(() => {
  return {
    opacity: 0,
    config: {
      duration: 500,
    },
  };
});

//useEffect等でステート管理
useEffect(() => {
  if (show && !visible) {
    setVisible(true);
    api.start({
      opacity: 1,
      onRest: () => {
        animationCallbackFunctions?.onEndShow?.();
      },
    });
    animationCallbackFunctions?.onStartShow?.();
  }
  if (!show && visible) {
    api.start({
      opacity: 0,
      onRest: () => {
        setVisible(false);
        animationCallbackFunctions?.onEndHide?.();
      },
    });
    animationCallbackFunctions?.onStartHide?.();
  }
}, [show, api, animationCallbackFunctions, visible]);

4. Framer Motion

Framer Motionも、Reactのアニメーションライブラリの1つである。
Framer Motionは今回の取り上げている問題を解決するために、AnimatePresenceというコンポーネントを用意している。
AnimatePresenceで囲んだ部分についてその内部で呼ばれるmotionコンポーネントについて、exitを指定することで、非表示の際にアニメーションを走らせてからコンポーネントをunmountすることができる機能がある。
これを使うと非常に楽に今回の問題を解決することができる。


<AnimatePresence>
  {show && (
    <motion.div
      initial={{ opacity: 0 }}
      animate={{ opacity: 1 }}
      exit={{ opacity: 0 }}
      transition={{ duration: 2 }}
      onAnimationStart={(def: any) => {
        if (def.opacity === 1) {
          animationCallbackFunctions?.onStartShow?.();
        } else {
          animationCallbackFunctions?.onStartHide?.();
        }
      }}
      onAnimationComplete={(def: any) => {
        if (def.opacity === 1) {
          animationCallbackFunctions?.onEndShow?.();
        } else {
          animationCallbackFunctions?.onEndHide?.();
        }
      }}
    >
      {children}
    </motion.div>
  )}
</AnimatePresence>

FramerMotionの欠点は、motionコンポーネントの中でanimationCallbackとして、onAnimationStartonAnimationCompleteしかない点だ。
これだと、initial -> animateのアニメーションが使用されたか、animate -> exitのアニメーションが使用されたかを判別するためのコードを書く必要があり、それを現在の状況に応じて分けて書く必要がある。
つまり、cssのみを切り出して、アニメーションを自由に指定できるコンポーネントを作るみたいな形にすることが難しいという点が少々気になった。
そのため、アニメーションごとに別々のコンポーネントを作る必要があるとは思う。
ただ、そのようなことをせずに、アニメーション実装したいというのであれば、最も簡単に実装できるライブラリだと思う。

まとめ

わざわざライブラリを使う必要があるのかというと、そうでもないと思った。普通のCSSで実装できるのであればそれで良いような気がした。
アニメーションライブラリを使用する点として、Springアニメーションを使用したいと言った時などが挙げられると思った。
Springアニメーションを内部実装したくない場合は、React SpringFramer Motionが良い選択肢だと思う。
ただ、React Springだと今回のような場合はコードが煩雑化するので、気軽に使いたいという場合はFramer Motionという選択肢が複雑なCallbackなどを必要としない場合は、どの方向にも整っていて良いと思った。

ちなみに今回実装したコードのレポジトリはこちら

まだまだ理解できてない部分あると思うので、間違っているところやこうすれば楽にできるよなどの改善点があれば教えていただけると嬉しいです。

Discussion