framer-motion과 Flubber를 이용해 우아한 svg 애니메이션 만들기

Ethan Yu·2023년 9월 10일
1

svg animation

[ Rockie Talkie ] 를 비롯한 React 기반의 개인 프로젝트에서 🔗 framer-motion을 사용하고 있습니다. 간결하지만 강력하고 확실한 기능을 보장하기 때문입니다. Framer-motion이 제공하는 독특한 기능 중 하나로 motionValueuseTransition 의 조합을 꼽을 수 있는데요, 선언적인 형태로 애니메이션 조작할 수 있다는 점에서 주목할 만합니다(애니메이션이라는 특성상 완전히 선언적일 수는 없지만요). 이 글에서는 framer의 motionValue와 useTransform, flubber의 조합을 통해 구현하기 어려운 svg 사이의 애니메이션을 우아하게 구현해보겠습니다.

🤔 motionValue와 useTransform

framer-motion의 모든 motion 컴포넌트는 내부적으로 motionValue 라는 값을 가지고 있습니다. motionValue는 애니메이션을 실행하는 데 사용되는 속도나 상태값의 표현이며, 애니메이션이 진행됨에 따라 변경되는 값(변수)입니다. framer-motion에서는 useMotionValue를 제공해 이 값을 제어할 수 있는 훅을 제공해주는데요, 이렇게 활용할 수 있습니다.

import { motion, useMotionValue } from "framer-motion";

const Component = () => {
  const x = useMotionValue(0);
  return <motion.div style={{ x }} />
}

export default Component;

이로써 우리는 useMotionValue의 초기값으로 0을 전달하고, 애니메이션에 의해 발생되는 x의 변화를 추적할 수 있습니다.

framer-motion은 useTransform이라는 훅 역시 제공하고 있는데요, useTransform을 통해 한motionValue의 변경에 따른 연쇄적인 효과를 생성할 수 있습니다. 이는 애니메이션에 대한 매우 강력한 제어권과 유연성을 부여할 수 있습니다. 형태의 변화를 동시적으로 발생시킬 수 있기 때문입니다.

Recoil의 Selectors의 개념에 익숙한 분들은 useTransform의 강력함에 대해 공감할 수 있으실 겁니다. 하나의 데이터로부터 파생된 데이터는 원본 데이터의 변경에 의존하기 때문에 매우 강력한 기능이라고 할 수 있습니다.

Derived state is a powerful concept because it lets us build dynamic data that depends on other data. - Recoil Selector에서 발췌

framer-motion의 useTransform은 특히 특정 범위 내에서 결정되는 의존적인 motionValue를 만든다는 점에서 더 강력합니다.

const x = useMotionValue(0);
const opacity = useTransform(
  x,
  // Map x from these values:
  [0, 100],
  // Into these values:
  [1, 0]
)

x라는 motionValue값이 0에서 100에서 변경될 때, useTransform으로 선언한 opacity의 값은 1에서 0으로 변경됩니다. 즉, 위치와 투명도가 서로 의존적인 관계를 맺고 동시에 변경되는 것입니다.

🌈 svg 애니메이션 구현하기

flubber를 살짝 얹으면 svg 사이의 애니메이션을 손쉽게 구현할 수 있습니다. 🔗 flubber 는 2d 형태로 구현된 문양 사이를 자연스럽게 interpolate 해주는 api를 제공합니다. flubber의 flubber.interpolate()를 이용하면 쉽게 svg 애니메이션을 구현할 수 있습니다.

interpolation: 우리말로 옮기면 '보간'. 필자에게도 낯선 단어이긴 하지만, 위에서 살펴본 useTransform의 사례가 0~100의 값을 1,0으로 interpolate해준다고 이해하면 될 듯합니다.

motionValueuseTransition, 그리고 커스텀 훅인 useFlubber을 통해 어떻게 애니메이션을 구현했는지 살펴보겠습니다.

1. useMotionValue와 useTransform으로 motionValue 생성하기

// App.tsx
const paths = [twitter, git, facebook, pinterest, twitter]; // svg strings
const colors = ["#16d1f2", "#000000", "#3b5998", "#CD201F", "#16d1f2"];

const App = () => {
  const [pathIndex, setPathIndex] = useState(0);
  const progress = useMotionValue(pathIndex);
  const fill = useTransform(progress, paths.map(getIndex), colors);
  const path = useFlubber(progress, paths);

  useEffect(() => {
    const animation = animate(progress, pathIndex, {
	  onComplete: () => {
        ...
        setPathIndex(pathIndex + 1);
        ...
      }
    });
    ...
  }, [pathIndex]);

  return (
	...
    <motion.path fill={fill} d={path} />
	...
  );
};

progress와 fill, path는 모두 motionValue입니다. 이때 progressfill 값은 서로 의존적입니다. progress의 값이 0에서 4(paths의 원소 개수와 동일합니다. 제작한 getIndex 함수는 [0, 1, 2, 3, 4]를 리턴)로 변할 때 fill의 값은 colors의 각 원소들(hex 색상값)로 변경됩니다. useTransform을 통해 새로운 범위의 값을 매핑해주었기 때문이죠. fill은 motion.path 컴포넌트의 fill 속성에 매핑되어, DOM 위에서 표현되는 DOM 엘리먼트의 색상을 담당하게 됩니다.

2. flubber 살짝 얹기

그렇다면 모양을 결정하는 path는 어떤 값을 가지고 있을까요? 제작한 useFlubber 훅을 살펴보도록 하겠습니다.

// use-flubber.tsx
const useFlubber = (progress: MotionValue<number>, paths: string[]) => {
  return useTransform(progress, paths.map(getIndex), paths, {
    mixer: (a, b) => interpolate(a, b, { maxSegmentLength: 0.1 }),
  });
}

useFlubberuseTransform을 내부적으로 감싸고 있는 커스텀 훅입니다. paths.map(getIndex)로 생성된 [0, 1, 2, 3, 4]의 값(progress가 가질 수 있는 값)은 다섯 가지의 모양(svg)를 원소로 하는 paths의 값과 interpolated됩니다.

핵심은 옵션으로 전달하는 mixer입니다. 문서를 확인해보면, mixer 옵션으로는 다음과 같은 함수를 전달할 수 있습니다.

mixer: (from: T, to: T) => (p: number) => any

mixer로 전달되는 함수의 인자 ab는 연속적으로 변경되는 데이터 결과값의 쌍을 의미합니다. 여기서는 paths의 값들이 되겠네요. 즉, {a, b}는 {twitter, git}, {git, facebook}, {facebook, pinterest}, {pinterest, twitter}의 다섯 가지 쌍을 의미합니다.

interpolate는 자연스럽게 (p: number) => any를 리턴한다고 생각할 수 있겠는데요, 예시로 이해해보겠습니다.

const interpolator = flubber.interpolate(triangle, octagon);

interpolator(0); // returns an SVG triangle path string
interpolator(0.5); // returns something halfway between the triangle and the octagon
interpolator(1); // returns an SVG octagon path string

flubber.interpolate는 일종의 커링 함수입니다. 0에서 1 사이의 인자를 전달받으면 두 개 모양(svg) 사이의 interpolated된 문양을 리턴합니다. 즉, (p: number) => string 함수를 리턴하는 셈입니다. 위 사례에서는 triangle과 octagon 사이의 모양이 분절적인 형태로 각 0 ~ 1에 할당되겠네요.

이 값, 많이 익숙하지 않나요? 맞습니다, mixer에 할당하는 함수 연쇄 일부와 동일합니다. flubber.interpolate를 사용해 두 문양 사이의 값을 결정할 수 있겠네요. maxSegmentLength를 통해 얼마나 촘촘하게 문양을 분절할 것인지를 전달할 수 있습니다. 이 값이 작으면 더 자연스럽게 모양이 변경될 수 있습니다(더 촘촘하게 잘리니까요).

mixer: (a, b) => interpolate(a, b, { maxSegmentLength: 0.1 })

🙌 마치며

useMotionValue, useTransform, flubber을 사용하여 svg 사이의 애니메이션을 구현해보았습니다. 서로 의존적인 데이터를 만들어보기도 하고, interpolation을 이용해보기도 했습니다.

styled-components에서도 Interpolated styles 원칙을 통해 컴포넌트(유니크한 클래스명)를 생성하는데요, 나중에 styled-components의 동작 원리를 정리하면서 Interpolation에 대해서 더 알아보도록 하겠습니다.

profile
🧐 사용자와 개발자를 모두 배려하고 싶은 개발자. 백엔드부터 임베디드까지 다양하게 개발하다가 지금은 🎨 프런트엔드에 자리잡았어요.

0개의 댓글