Framer Motion 찍먹하기

김 주현·2023년 7월 11일
1

노션 방명록 위젯 Ver2를 만들면서 이제 다 만들었다~ 싶었는데, 하나 빼먹는 게 있었다. 그건 바로 애니메이션 ... !

굳이 넣어야 하는 건 아니지만, 사용자 경험에서 애니메이션이 차지하는 부분은 꽤나 크기 때문에 만족스러운 UX를 위해서 애니메이션을 넣어주는 것이 좋다.

또, 이전 버전에서는 이렇게 하면 되나~? 싶은 생각으로 코드를 짰더니 중복되는 코드도 많고 관리가 안 되어있었기에, 이번에 Framer Motion을 정리하면서 적절하게 적용해보려고 한다.

Framer Motion 설치

Framer Motion은 공식 사이트를 지원한다. 그렇다는 말은? 그냥 가서 하라는 대로 하면 된다(...)

Installation

npm install framer-motion

Importing

import { motion } from "framer-motion"

Framer Motion 동작 원리

기본적으로 Framer Motion은 컴포넌트 형식으로 사용한다. 다르게 말하면, 내부적으로 div를 감싸고 있는 일종의 HOC라고 볼 수 있다. 예를 들면 이런 식이다.

// motion
const motion = {
  const div = ({children, ...props}) => ({
    <div {...props}>{children}</div>
  })

  return {
    div
  }
<!-- Components -->
<motion.div>안녕안녕</motion.div>

이렇게 한 단계 위로 HTML Element를 감싸줌으로써, 직접 translate, transform 같이 애니메이션 관련 속성을 좀 더 편하게 쓸 수 있게끔 하는 것이다. 이런 점을 알면, emotion/styled이나 styled-component에서도 다음과 같이 사용할 수 있다.

const StyledApp = {
  Contaienr: styled(motion.div)`
	display: flex;
  `
}

애니메이션 적용

그래서 애니메이션을 어떻게 적용시키냐면, Framer Motion에서 지정해놓은 특정 속성을 넘겨주면 된다. 그렇지만서도 지원하는 특정 속성이 꽤나 많기 때문에, 이와 관련된 건 공식 문서를 보면서 살펴보는 것을 추천! 여기에서는 내가 필요한 부분들만 짚어보겠다.

기본 동작

내가 생각하기에 Framer Motion은 initial, animate, transition, exit 정도만 알면 간단하고 웬만한 애니메이션은 구현할 수 있는 것 같다.

  • initial: 초기 상태, 애니메이션이 일어나기 전에 Default 상태
  • animate: 변화 상태, 애니메이션이 일어나면 지정한 속성으로 변화
  • exit: 제거 상태, Element가 DOM에서 사라질 때 일어날 변화
    - 이 경우에는 DOM에서 detach되는 걸 알아차려야 하기 때문에, Framer Motion에서 제공하는 AnimatePresence으로 감싸줘야 한다. 아마 Context의 일종인 듯?
  • transition: 애니메이션 속성, animate 상태가 되어 속성이 변화할 때, 어떤 식으로 변화할지 결정. Ease-in-out이라든가 damping이라든가 등등.

가볍게 fade-in 되는 애니메이션을 넣어보자.

Fade-In

아 진짜 모션 개열받네ㅋㅋ

      <motion.div className="big"
        initial={{opacity: 0, scale: 0.5}}
        animate={{opacity: 1, scale: 1.0}}
        transition={{duration: 1}}
      >
        안녕안녕
      </motion.div>

initialanimate에 각각 원하는 효과를 적은 객체를 지정해주면 아주 손쉽게 애니메이션을 구현할 수 있다!

AnimatePresence

그렇다면 이제 DOM에서 제거되는 애니메이션도 넣어보자. Framer motion에서 제공하는 exit 속성을 활용하면 되는데, 사실 이 속성만 넣으면 되는 건 아니다. 따로 AnimatePresence라는 것 안에 넣어줘야 한다. 요놈은 DOM Tree에 추가/제거되는 이벤트를 캐치해주는 역할을 한다.

제거되는 애니메이션

한껏 더 열받아

      <AnimatePresence>
        {isOpen && (
          <motion.div
            className="big"
            initial={{ opacity: 0, scale: 0.5 }}
            animate={{ opacity: 1, scale: 1.0 }}
            exit={{ opacity: 0, scale: 2.0 }}
            transition={{ duration: 1 }}
          >
            안녕안녕
          </motion.div>
        )}
      </AnimatePresence>

AnimatePresence의 위치는 당연히 조건부 렌더 분기를 감싸주어야 한다. 앞서 설명한 것처럼 DOM에서 Detach 되는 순간을 알아내야 하기 때문이다. 이때, "직계 자손(direct children)" 이어야 한다.

AnimatePresence works by detecting when direct children are removed from the React tree.

공식 문서에서도 강조를 하고 있다. 직계 자손이라는 뜻은 애니메이션이 적용될 Element를 바로 감싸줘야 한다는 뜻이다. 예를 들어 아래와 같은 상황은 적용되지 않는다.

<AnimatePresence>
	<ul>
    	{lists.map(item => <motion.div key={item.id}>{item.data}</motion.div>)}
    </ul>
</AnimatePresence>

위의 코드는 직계 자손이 ul Element인데, ul Element 자식에서 일어나는 DOM Detach는 알아낼 수 없는 모양이다. 그러므로, 아래처럼 ul안에 AnimatePresence를 넣는 것이 바람직!

<ul>
	<AnimatePresence>
    	{lists.map(item => <motion.div key={item.id}>{item.data}</motion.div>)}
    </AnimatePresence>
</ul>

제거되는 애니메이션에서 조금 살펴봐야 할 것은, 애니메이션이 진행되고 나서 DOM에서 제거가 된다는 점이다. 당연한 사실이지만 은근히 이 부분을 인지 못하고 삽질을 해버리는 경우가 더러 있다. 내 얘기 아님. 암튼 아님.

popLayout과 layout

애니메이션이 진행되는 동안 Layout의 변화는 없기 때문에, 애니메이션이 끝나고 나서 버튼의 위치가 바로 위로 가버리는 게 꽤나 열받는다. Framer Motion 쪽에서도 다행히 이 부분에 대해 인지가 있었는지 새로운 layout에 대응할 수 있도록 지원해놓은 게 있었다. AnimatePresence의 mode 속성을 이용하면 된다.

popLayout

      <AnimatePresence mode="popLayout">
        {lists.map(({ id, value }) => (
          <motion.div
            className="big"
            initial={{ scale: 0.8, opacity: 0 }}
            animate={{ scale: 1, opacity: 1 }}
            exit={{ scale: 0.8, opacity: 0 }}
            transition={{ type: "spring", duration:1 }}
            key={id}
            onClick={() => removeItem(id)}
            layout
          >
            {value}
          </motion.div>
        ))}
      </AnimatePresence>

꼭 렌더시킬 Element들에 layout 속성을 넣어주어야 정상 작동한다. layout 속성은 말그대로 이런 레이아웃과 관련되어 애니메이션이 들어간다면 따로 써주어야 한다.

유의사항

그리고 이건 내 관련 경험담인데, AnimatePresence 안에 DOM Deatch되는 Element가 여러 개라면, 먼저 발현된 애니메이션 Element에만 Exit 이펙트가 적용되는 경우가 더러 있었다.

Exit animation issus

분명 적어놨는데 어떤 건 되고 어떤 건 안 되니 머리를 깨부수고 싶었다. 하지만 내 머리는 소중하니까,, 하지만 이 따위 머리라면... 하지만.. 하지만...

해결 방법은 역시 공식 문서에 있었다. 다음과 같은 설명이 있었다.

AnimatePresence works the same way with multiple children. Just ensure that each has a unique key and components will animate in and out as they're added or removed from the tree.

그러니까, 컴포넌트에 고유한 키를 지정하게 되면 추적할 수 있게 되어서 여러 자식들에 대해서도 작동한다는 말이다.

Multiple children exit

      <AnimatePresence>
        {isOpen && (
          <motion.div
            key="1"
            className="big"
            initial={{ opacity: 0, scale: 0.5 }}
            animate={{ opacity: 1, scale: 1.0 }}
            exit={{ opacity: 0, scale: 2.0 }}
            transition={{ duration: 1 }}
          >
            안녕안녕
          </motion.div>
        )}
        {isOpenB && (
          <motion.div
            key="2"
            className="big"
            initial={{ opacity: 0, scale: 0.5 }}
            animate={{ opacity: 1, scale: 1.0 }}
            exit={{ opacity: 0, scale: 2.0 }}
            transition={{ duration: 1 }}
          >
            안녕안녕2
          </motion.div>
        )}
      </AnimatePresence>

목록 애니메이션

여러 자식들에게 애니메이션을 주는 이야기가 나왔으니, 목록에 대한 것도 찍먹해보자. 아마 여러 상황에서, API로 데이터를 가져와서 보여주는 경우가 많을 것이다.

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

import './App.css';

const myData = [
  {
    id: 1,
    name: '김주현',
    content: '안녕안녕1',
  },
  {
    id: 2,
    name: '김주핸',
    content: '안녕안녕2',
  },
  {
    id: 3,
    name: '김주현주핸',
    content: '안녕안녕3',
  },
  {
    id: 4,
    name: '주현김',
    content: '안녕안녕4',
  },
  {
    id: 5,
    name: '주핸김',
    content: '안녕안녕5',
  },
];

export default function App() {
  const [lists, setLists] = useState([...myData]);
  const removeItem = (removeId) => setLists(lists.filter(({ id }) => id !== removeId));

  return (
    <div className="App">
      <ul className="group">
        <AnimatePresence mode="popLayout">
          {lists.map((data) => (
            <motion.li
              key={data.id}
              className="item"
              initial={{ opacity: 0, x: -40 }}
              animate={{ opacity: 1, x: 0 }}
              exit={{ opacity: 0, x: 40 }}
              transition={{"type": "spring"}}
              onClick={() => removeItem(data.id)}
              layout
            >
              <span>{data.name}</span>
              <span>{data.content}</span>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>
    </div>
  );
}

뭐 이런 식의 코드가 있다고 치자. 이걸 돌려보면 아래와 같이 나온다.

List rendering

(Note) box-sizing 이슈

잠깐 이상한 점을 짚고 가겠다. 이건 framer motion에도 안 나와있던데, 없어질 때 가만히 보면 되게 이상하게 지워지는 것을 확인할 수 있다. 좀 더 확실하게 느리게 보여주면 다음과 같다.

framer motion scale issue

내가 지정하지 않은 scale까지 먹혀 들어가는 것을 볼 수 있는데, 이게 왜 그런가 싶었더니... box-sizing 문제였다. box-sizeing: border-box 로 설정해주면 문제가 해결된다.

box-sizing

진짜 이상하게 효과 먹히는 거 보고 개열받았는데 어쨌든... 해결했다.

Variants

초기 목록을 보여줄 때 적용하면 좋은 속성이 있다. 바로 delaystaggerChildren 되시겠다. 이 속성을 이용하면 꽤나 만족스러운 애니메이션을 적용할 수 있는데, 이것을 해보기 위해선 먼저 variants 개념에 대해서 알아야 한다.

어려운 건 아니고, 말 그대로 변수의 의미이다. 지금까지 우리가 적어왔던 방식으로 하나하나 객체를 정의해서 initial, animate, exit prop에 넘겨주게 된다면, 적용할 효과가 많아지고 디테일한 속성을 많이 만지다보면 가독성이 나락으로 가버릴지도 모른다. 이것을 좀 더 편하게 관리하기 위해 미리 정의해두고 가져다가 쓰는 것.

variants를 쓰면 이런 이점만 있는 게 아니라, 여러 상태의 애니메이션을 정의할 수 있다. 예를 들어 어떤 리액트 상태에 따라 적용시키고 싶은 애니메이션이 다르다면, variants를 사용해서 간편하게 적용할 수 있다.

말로 이러저러하지 말고 실제로 눈으로 보는 게 더 빠른 법!

// Variants 쓰기 전
<motion.li
  initial={{ opacity: 0, x: 400 }}
  animate={{ opacity: 1, x: 0 }}
  exit={{ opacity: 0, x: 40 }}
>
                
// Variants 쓴 후
const animateVariants = {
  hiddenState: { opacity: 0, x: 400 },
  showState: { opacity: 1, x: 0 },
  zoomState: { opacity: 1, x: 0, scale: 2 }
  hideState: { opacity: 0, x: 40 },
};

<motion.li
  variants={animateVariants}
  initial="hiddenState"
  animate={isZoom ? "zoomState" : "showState"}
  exit="removeState"
>

애니메이션에 관한 속성들을 따로 animateVariants 변수로 뺀 다음, 그 변수를 variants 속성에 적용시킨 다음, initial, animate, exit에는 animateVariants 안에서 선언한 것들을 사용하면 됩니다. 아주 깔끔하쥬?

Propagation

Variants를 사용한 모션 컴포넌트가 자식을 가진다면, 그 자식이 본인만의 animate 속성을 지정하기 전까진 부모의 애니메이션 속성이 상속됩니다. 이게 무슨 말이냐면~

const list = {
  visible: { opacity: 1 },
  hidden: { opacity: 0 },
}

const item = {
  visible: { opacity: 1, x: 0 },
  hidden: { opacity: 0, x: -100 },
}

return (
  <motion.ul
    initial="hidden"
    animate="visible"
    variants={list}
  >
    <motion.li variants={item} />
    <motion.li variants={item} />
    <motion.li variants={item} />
  </motion.ul>
)

원래대로라면 motion.liinitial, animate 속성에도 각각 hiddenvisible을 지정해주어야 했지만, 부모 motion.ulinitialanimate에 지정을 해주었기 때문에, 굳이 쓰지 않더라도 motion.li에 그대로 적용된다는 말입니다.

Orchestration

위의 특징 덕분에 Variants를 사용하면 오케스트레이션(Orchestration)라는 효과를 사용할 수 있는데,, 이게 참 우리 말로 번역하기 어렵다. 그냥 말하자면 자식들의 애니메이션 구성에 대해 관여할 수 있는 효과이다. 예를 들어, 첫 번째 순서부터 나타나게 한다든지, 부모의 애니메이션이 다 끝나고 자식들 효과가 적용된다든지, 반복한다든지 등등.. 뇌피셜로 그렇게 연속적인 자식 아이템들의 애니메이션들에 공통적인 방향을 정해주어서 조화롭게 애니메이션 연출 구성할 수 있기 때문에 오케스트레이션이 아닌가 싶다.

Framer motion에서 제공하는 속성은 delayChildren, staggerChildren, staggerDirection, when, 그리고 반복에 관한 것(repeat, repeatType, repeatDelay)이 있다. 요 부분들은 공식 문서에 잘 나와있으니, 내가 필요한 부분만 좀 정리해보겠다!

delayChildren

앞서 말했던 delaystaggerChildren 중에 먼저 delay를 다뤄보자. 기본적으로 제공하는 속성 중에 delay라는 것이 있는데, delay는 말 그대로 애니메이션을 지연시키는 동작이다. 값을 주게 되면 해당 시간 후에 애니메이션 동작한다.

const animateVariants = {
  showState: { opacity: 1, x: 0, transition: { delay: 1 } },
};

Delay Effect

재밌는 건, 이 Delay 효과를 부모 컨테이너에서 관리해줄 수 있다. 그것이 바로 delayChildren! 이것의 기본 전제는 부모의 애니메이션 프로퍼티를 그대로 받아왔다는 점이다. 이 속성을 적용하면 모션 컴포넌트 자식은 해당 시간만큼 나중에 애니메이션이 재생된다.

delayChildren

const containerVariants = {
  hiddenState: { opacity: 0, y: 40 },
  showState: { opacity: 1, y: 0, transition: { duration: 1, delayChildren: 1 } },
};

const itemVariants = {
  hiddenState: { opacity: 0, x: 400 },
  showState: { opacity: 1, x: 0 },
  removeState: { opacity: 0, y: 40 },
};

return (
      <motion.ul
        className="group"
        variants={containerVariants}
        initial="hiddenState"
        animate="showState"
      >
        1초 뒤에 나타납니당
        <AnimatePresence mode="popLayout">
          {lists.map((data) => (
            <motion.li
              key={data.id}
              className="item"
              variants={itemVariants}
              transition={{ type: 'spring' }}
              onClick={() => removeItem(data.id)}
              layout
            >
              <span>{data.name}</span>
              <span>{data.content}</span>
            </motion.li>
          ))}
        </AnimatePresence>
      </motion.ul>
)

staggerChildren

그 다음은 아주 내가 좋아하는 속성인데, 각각의 자식 애니메이션에 딜레이를 연속적으로 주는 것이다(stagger). 그러면 좌라락~ 애니메이션이 재생된다. 어휘력 미쳐 진짜 ㅋㅋ

staggerChildren

const containerVariants = {
  hiddenState: { opacity: 0, y: 40 },
  showState: { opacity: 1, y: 0, transition: { duration: 1, staggerChildren: 0.1 } },
};

staggerDirection

반대로도 가능하다.

staggerDirection

const containerVariants = {
  hiddenState: { opacity: 0, y: 40 },
  showState: {
    opacity: 1,
    y: 0,
    transition: { duration: 1, staggerChildren: 0.1, staggerDirection: -1 },
  },
};

when

지금 보면, 부모의 애니메이션과 자식의 애니메이션이 동시에 재생되고 있는 걸 확인할 수 있다. 자식의 애니메이션 시작 시기를 조정할 순 없을까?

그럴 때 필요한 것이 바로 이 when 속성이다. 이 속성에는 3가지의 속성이 있다.

  • false: 설정 안 함. 그대로 같이 시작한다.
  • "beforeChildren": 자식 애니메이션이 나오기 전에 본인의 애니메이션을 끝낸다.
  • "afterChildren": 자식 애니메이션이 마무리 된 후에 본인의 애니메이션을 시작한다.

이 속성은 어떻게 쓰는 게 좋냐면, 시작하는 애니메이션일 땐 beforeChildren으로 부모의 애니메이션을 보장해주고, 끝내는 애니메이션일 땐 afterChildren으로 자식의 애니메이션을 보장해주는 것이 좋다.

  • beforeChildren
    beforeChildren
  showState: {
    opacity: 1,
    y: 0,
    transition: { duration: 1, when: 'beforeChildren', staggerChildren: 0.1 },
  },
  • afterChildren
    afterChildren
  removeState: {
    opacity: 0,
    y: -40,
    transition: { duration: 1, when: 'afterChildren', staggerChildren: 0.1 },
  }
  • 전체 코드
      <AnimatePresence mode="popLayout">
        {isOpen && (
          <motion.ul
            key="group"
            className="group"
            variants={containerVariants}
            initial="hiddenState"
            animate="showState"
            exit="removeState"
          >
            자식 애니메이션 끝나고 부모 애니메이션
            {lists.map((data) => (
              <motion.li
                key={data.id}
                className="item"
                variants={itemVariants}
                transition={{ type: 'spring' }}
                onClick={() => removeItem(data.id)}
                layout
              >
                <span>{data.name}</span>
                <span>{data.content}</span>
              </motion.li>
            ))}
          </motion.ul>
        )}
      </AnimatePresence>

(Note) Varitants 버그?

예제를 작성하다보니까 좀 이상한 걸 발견했는데, 이게 내가 관련 지식이 없어서 그런 건지, 아니면 Framer Motion의 버그인 건지 모르겠다.

내가 원하는 것: 부모가 뜬 다음 자식들이 순서대로 뜨고, 부모가 사라질 땐 자식들이 순서대로 사라지고, 그 다음 부모가 사라지는 걸 원하는 상황인데, 중간에 특정 자식을 클릭해서 지웠을 때도 사라지는 애니메이션이 나오게 하고 싶음

현재 문제 상황: 부모와 자식의 애니메이션 재생 시기는 whenstaggerChildren으로 잘 처리했는데, 특정 자식을 지웠을 때의 exit 효과가 나타나지 않음.

부모에 variants를 적용했고, exit 효과도 주었음. 그렇다면, 자식에 따로 애니메이트 속성을 지정하지 않았으니 자식은 부모의 애니메이트 속성을 상속 받음. (exit 역시 상속 받는다고 알고 있음) 그러면, 자식이 onClick으로 인해서 삭제가 되면 exit 효과가 나타나야 하는 것 같은데, 무시됨. 부모가 제거됐을 때만 exit 효과가 나타남.

시도1: 당연한 것 같기도 한 게, AnimatePresence는 직접 자식의 dom tree 제거만 감지하기 때문에, ul 안에 있는 li는 감지하지 못 해서 그냥 삭제가 되는 것 같기도 하다는 생각이 듦. 그래서 li를 다시 AnimatePresence로 감싸줌.

시도1 결과: 택도 없음

고민: 뭔가.. 뭔가임. 여기까진 내 실력의 한계라 생각하며 넘어가보겠다...

후기

마지막에 이상하게 잘 안 풀리면서 애매하게 찍먹이 끝났는데, 뭐어 어쨌든 나름대로 정리가 된 것 같다. 이후엔 유튜브에서 다른 사람들은 Framer motion을 어떻게 썼는지 찾아보면서 코드 구성을 좀 익혀가는 걸로!

profile
FE개발자 가보자고🥳

0개의 댓글