본문 바로가기

FrontEnd

[React, framer-motion] 언마운트 대상 DOM 요소에 애니메이션 적용하기 with AnimatePresence

반응형

아래와 같은 360도 캐러셀을 개발하려면, 자바스크립트를 사용해아 한다.

주로 react-slick을 사용할 것이다.

이러한 완성형 라이브러리들의 단점은 다음과 같다.

  • UI 기능이 필요할 때마다 하나하나 깔다보면 라이브러리 갯수가 많아진다
    • masonry
    • calendar
    • carousel
    • date-picker 등등...
  • 스타일 커스터마이징이 어렵다.
    • JS는 몇줄 안되는데 파일 하나가 CSS 조정하는데 다 쓰인다. (ex: MUI)

본인의 경우 해당 링크의 캐러셀과 유사하지만, 가운데로 오면 크기도 바뀌고, 컴포넌트도 바뀌는 캐러샐을 구현할 일이 있었다.

3D 캐러셀

 

이런 캐러셀을 React-Slick만으로 구현하는건 쉽지 않을 것이다.

또한 이런 라이브러리들에 강제로 컴포넌트를 끼워넣다보면, 기능 및 스타일, 레이아웃의 관심사가 총제적으로 라이브러리에 엮여버리는 일이 왕왕 있다.

 

본인은 필요한 UI 컴포넌트와 UI 기능을

Presentational Component / Container Component 혹은 hook으로 따로 개발하는 것을 선호한다.

이러한 컴포넌트들의 시각적 동작(motion)에 최적화된 라이브러리인 framer-motion이 존재한다.

해당 라이브러리를 사용하면 다른 컴포넌트의 시각적 표현에 무관하게 원하는대로 애니메이션만 쉽게 적용할 수 있다.


exit motion의 난해함

위의 기능 구현이 특히 리액트로 어려운 이유는,

이미 리액트 컴포넌트 트리 상에 존재하지 않는 dom 요소들과의 오케스트레이션이 필요하기 때문이다.

framer-motion의 다재다능한 기능 중 AnimatePresence를 이용하면 이를 어렵지 않게 구현할 수 있다.

 

아래 코드는 AnimatePresence가 없었다면, 눈 앞에서 바로 사라졌을 것이다.

하지만 framer-motion 덕택에 2초 뒤에 불투명도가 0이 되어 사라진다.

import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";

export default function App() {
  const [show, setShow] = useState(true);

  return (
    <>
      <button type="button" onClick={() => setShow(!show)}>
        Show / Unshow
      </button>
      <AnimatePresence>
        {show ? (
          <motion.p exit={{ opacity: 0 }}           
          transition={{     
            opacity: { duration: 2 }
          }}>
            Animated content
          </motion.p>
        ) : null}
      </AnimatePresence>
    </>
  );
}

framer-motion은 그렇다면 이러한 애니메이션을 어떻게 가능하게 하는 것일까?

힌트 : React.useRef


Under the hood

프레이머 모션의 동작은 아래와 같다.

  • exit, initial, amimate를 통해 각각 언마운트, 마운트, 프롭 조작 시 애니메이션을 실행할 수 있다.
  • styled-component처럼 motion['tag'].컴포넌트 렌더링 함수를 노출한다.
    • 위 코드 예제에서 motion.p 태그를 이미 보았다.

이를 이용하 간단한 클론을 만들어보자.

클론의 명세는 다음과 같다.

  • onExit, onEnter 함수를 통해 언마운트, 마운트 시 애니메이션을 실행한다
  • 웹 애니메이션 API를 사용한다
  • motion 오브젝트를 통해 태그를 노출하여 애니메이션을 가능하게 한다.
    • 일단 p만 만든다.
참고 : 애니메이션 웹 API를 이용해 애니메이션 만들기(Let's do some animations in native Javascript)

위의 명세에서 이야기한 대로, useEffect를 활용해 시작 및 종료 애니메이션을 트리거하는 구현을 만들어 보자.

const AnimatedComponent =
  (Tag) =>
  ({ onExit, onEnter, ...otherProps }) => {
    const elementRef = useRef(null);

    useEffect(() => {
      const animation = elementRef.current.animate(
        onEnter,
        {
          duration: 2000,
          fill: "forwards",
        }
      );

      return () => {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: "forwards",
          }
        );
        animation.commitStyles();
      };
      // I don't include onEnter and onExit as dependency
      // Because only want them at mount and unmount
      // Could use references to satisfy the eslint rule but
      // too much boilerplate code
    }, []);

    return <Tag {...otherProps} ref={elementRef} />;
  };

const motion = {
  p: AnimatedComponent("p"),
};

이 컴포넌트를 사용하면 애니메이션이 동작할까? 답은 아니오다.

첫번째 문단에서 설명한 것과 같이, 이미 DOM에 없는 것에 애니메이션을 적용할 수는 없기 때문이다.

 

해결 방법?

가장 간단한 방법은 isVisible 상태를 도입하는 것이다.

그러면 visibility를 나타내는 상태를 통해 애니메이션을 트리거 할 수 있다.

const AnimatedComponent =
  (Tag) =>
  ({ onExit, onEnter, isVisible, ...otherProps }) => {
    const elementRef = useRef(null);

    useEffect(() => {
      if (isVisible) {
        const animation = elementRef.current.animate(
          onEnter,
          {
            duration: 2000,
            fill: "forwards",
          }
        );

        return () => animation.cancel();
      } else {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: "forwards",
          }
        );
        animation.commitStyles();

        return () => animation.cancel();
      }
    }, [isVisible]);

    return <Tag {...otherProps} ref={elementRef} />;
  };

이제 이 방법의 문제는 뭘까?

  • 일단 플래그 하나를 개발자가 직접 관리해야 한다.
    • 이는 호불호, 필수불가결의 영역일 수도 있으니 넘어가자.
  • isVisible은 컴포넌트 트리, DOM 트리 상에서 실제로 DOM을 제거하지 않는다.
    • 시각적으로만 숨길 뿐이다.

우리는 일반적인 리액트 개발처럼, 컴포넌트를 호출해서 리액트 엘리먼트를 만들고, 그 녀석들을 토대로 돔을 만들고 싶다.

즉 컴포넌트가 리턴하지 않는 요소는 돔에서 사라져야 하는 것이다.

하지만 이 요소들의 삭제 뿐만 아니라 삭제 시 애니메이션도 리액트(혹은 라이브러리)에 위임하고 싶다.

이 삭제 시 애니메이션을 오케스트레이션 해주는 컴포넌트가 AnimatePresence다.


AnimatePresence의 핵심 기능

핵심 두 가지 기능은 다음과 같다.

  • 각 렌더링 마다 제거되는 컴포넌트를 감지
  • 언마운트 대상 자식 컴포넌트의 참조를 애니메이션 동안 유지

이 기능을 위해선 각 자식 컴포넌트의 구별이 필요하다.
자식 컴포넌트들의 구별을 위해선 key를 사용한다.

이는 리액트의 컨텍스트에서 매우 자연스럽다.

삭제 대상 요소 감지

이를 위해 몇가지 유틸리티 함수를 사용한다

  • React.Children.forEach를 이용해 자식 컴포넌트 반복
  • React.isValidElement를 이용해 React 엘리먼트 유효성 검사

주의사항

  • key는 1 depth의 리액트 엘리먼트에 있음. prop에 존재하지 않음

유효한 children 필터링

function getAllValidChildren(children) {
  const validChildren = [];

  React.Children.forEach(children, (child) => {
    if (React.isValidElement(child)) {
      validChildren.push(child);
    }
  });

  return validChildren;
}

직전 렌더링 결과 컴포넌트 유지

렌더링 이후에 유효한 ref로 업데이트 해 준다면, 이전 값은 이전 렌더링 시 업데이트된 dom의 참조다.

import { useRef, useLayoutEffect } from "react";

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });
}

언마운트 대상 키를 식별하기

키는 prop에 있는게 아니라 element 객체에 있다.

function getKey(element) {
  // I just define a default key in case the user did
  // not put one, for example if single child
  return element.key ?? "defaultKey";
}

보통 이러한 라이브러리들은 내부적으로는 유저가 키를 제공하지 않는 케이스를 대비해 useId나 uuid같은 함수를 사용한다.

UI 기능 위주의 컴포넌트를 개발하다 보면 사용자가 key를 제공하지 못하거나 제공할 필요가 없는 경우에도 key가 필요한 상황이 많다.

import { useRef, useLayoutEffect } from "react";

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter((key) => !currentKeys.includes(key))
  );
}

언마운트 대상 엘리먼트 가져오기

키를 이용한 엘리먼트 해시테이블(딕셔너리)를 만들면 편하다.

사실 js 네이티브 map을 사용하는게 더 편할수도 있으나, 참조한 게시물의 코드를 첨부한다.

해당 코드는 JS Object를 딕셔너리로 사용한다

function getElementByKeyMap(validChildren, map) {
  return validChildren.reduce((acc, child) => {
    const key = getKey(child);
    acc[key] = child;
    return acc;
  }, map);
}
그리고 해당 map을 ref에 유지한다.
import { useRef, useLayoutEffect } from "react";

function AnimatePresence({ children }) {
  const validChildren = getAllValidChildren(children);
  const childrenOfPreviousRender = useRef(validChildren);
  const elementByKey = useRef(
    getElementByKeyMap(validChildren, {})
  );

  useLayoutEffect(() => {
    childrenOfPreviousRender.current = validChildren;
  });

  useLayoutEffect(() => {
    elementByKey.current = getElementByKeyMap(
      validChildren,
      elementByKey.current
    );
  });

  const currentKeys = validChildren.map(getKey);
  const previousKeys =
    childrenOfPreviousRender.current.map(getKey);
  const removedChildrenKey = new Set(
    previousKeys.filter((key) => !currentKeys.includes(key))
  );

  // And now we can get removed elements from elementByKey
}

이제 제거되는 엘리먼트 감지, 레퍼런스 유지 함수의 구현에 대해 알아보았다.

그렇다면 AnimatePresence는 해당 함수들을 어떻게 사용해서 기능을 만들까?


AnimatePresence의 동작

이전에 첨부한 AnimatedComponent 구현은...

  • useEffect 덕택에 마운트, 언마운트, 업데이트 시 애니메이션의 실행이 가능하다.
  • isVisible 플래그를 이용해 애니메이션의 실행 여부 판단이 가능하다.

이 isVisible를 상태로 모든 컴포넌트 별로 사용해 구현하는 대신,

AnimatePresence가 컴포넌트에 prop으로 주입하게 하면 어떨까?

AnimatePresence는 React.cloneElement API를 이용하여 해당 기능을 구현한다.

  • isVisible이 true면 계속 마운트되어 있는 요소다
  • isVisible이 false면 언마운트 대상이다.
  // 유효한 children component만 보임
  const childrenToRender = validChildren.map((child) =>
    React.cloneElement(child, { isVisible: true })
  );

  // isVisible이 false면 언마운트 대상
  removedChildrenKey.forEach((removedKey) => {
    // 직전 렌더링된 삭제 대상 요소
    const element = elementByKey.current[removedKey];
    // 직전 렌더링된 삭제 대상 인덱스
    const elementIndex = previousKeys.indexOf(removedKey);
    // 삭제 대상 요소를 isVisible: false로 대체
    childrenToRender.splice(
      elementIndex,
      0,
      React.cloneElement(element, { isVisible: false })
    );
  });

  // children 대신 전처리된 chuldren 리턴
return  removedChildrenKey

잘 동작하는 것처럼 보이나, 한 문제가 남아있다.

요소가 DOM 트리에 남아있다.


DOM에 남아있는 요소 제거

아래 두 가지 아이디어를 활용한다.

  • animation.finished가 리턴하는 promise를 이용한다.
  • 애니메이션이 종료되면 AnimatePresence를 리렌더링한다

useForceRender hook

AnimatePresence를 강제로 리렌더링 하기 위한 훅이다.

import { useState, useCallback } from "react";

function useForceRender() {
  const [_, setCount] = useState(0);

  return useCallback(
    () => setCount((prev) => prev + 1),
    []
  );
}

애니메이션 종료 시 리렌더링

AnimatePresence

onExitAnimationDone 콜백을 isVisible과 같이 주입한다.

해당 콜백은 다음 렌더링 시 삭제 대상 컴포넌트를 map에서 제거한 뒤, AnimatePresence를 강제로 다시 한번 렌더링 하도록 한다.

  // isVisible이 false면 언마운트 대상
  removedChildrenKey.forEach((removedKey) => {
    // 직전 렌더링된 삭제 대상 요소
    const element = elementByKey.current[removedKey];
    // 직전 렌더링된 삭제 대상 인덱스
    const elementIndex = previousKeys.indexOf(removedKey);
    
    // 애니메이션 종료 시 실행할 콜백
    const onExitAnimationDone = () => {
      removedChildrenKey.delete(removedKey);

      if (!removedChildrenKey.size) {
        forceRender();
      }
    };
    // 삭제 대상 요소를 isVisible: false로 대체
    // onExitAnimationDone 프롭을 주입
    childrenToRender.splice(
      elementIndex,
      0,
      React.cloneElement(element, { isVisible: false , onExitAnimationDone})
    );
  });

AnimatedComponent

AnimatePresence가 주입해준 onExitAnimationDone 콜백을

웹 애니메이션 API 실행 완료 시 호출한다.

    useEffect(() => {
      if (isVisible) {
        const animation = elementRef.current.animate(
          onEnter,
          {
            duration: 2000,
            fill: "forwards",
          }
        );

        return () => animation.cancel();
      } else {
        const animation = elementRef.current.animate(
          onExit,
          {
            duration: 2000,
            fill: "forwards",
          }
        );
        animation.commitStyles();
        // When the animation has ended
        // we call `onExitAnimationDone`
        animation.finished.then(onExitAnimationDone);

        return () => animation.cancel();
      }
    }, [isVisible]);

 


More On Advanced Animation Implementation

애니메이션은 그래픽스의 슈퍼셋이라 볼 수 있기 때문에, 단순한 주제가 아니다.

위 내용보다 더 심화된 주제를 다루는 강의가 있다.

시간이 허락하면 학습 후 관련 내용을 포스팅 하도록 하겠다.

 

https://frontendmasters.com/courses/css-animations/

 

CSS Animations and Transitions | Learn CSS Animations from Basics to Advanced

Learn CSS transitions, animations, custom properties, data attributes, choreography, animation states, layout animations, and reactive animations with a little bit of JavaScript.

frontendmasters.com


참고

https://dev.to/romaintrotard/exit-animation-with-framer-motion-demystified

 

Exit animation with `framer-motion` demystified

Unlike Svelte which has built-in animation and transition, React does not. If you have worked with...

dev.to

https://jaeseokim.dev

 

Framer Motion의 AnimatePresence 동작 원리 분석하기!

Framer Motion의 AnimatePresence 동작 원리 분석하기! Framer Motion의 AnimatePresence 또는 React Transition Group를 사용해본 개발자라면 공통적으로 떠오르게 되는 의문점이 하나 있습니다. 어떻게 UnMount 된 Componen

jaeseokim.dev

 

반응형