[ Rockie Talkie ] 를 비롯한 React 기반의 개인 프로젝트에서 🔗 framer-motion을 사용하고 있습니다. 간결하지만 강력하고 확실한 기능을 보장하기 때문입니다. Framer-motion이 제공하는 독특한 기능 중 하나로 motionValue
와 useTransition
의 조합을 꼽을 수 있는데요, 선언적인 형태로 애니메이션 조작할 수 있다는 점에서 주목할 만합니다(애니메이션이라는 특성상 완전히 선언적일 수는 없지만요). 이 글에서는 framer의 motionValue와 useTransform, flubber의 조합을 통해 구현하기 어려운 svg 사이의 애니메이션을 우아하게 구현해보겠습니다.
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으로 변경됩니다. 즉, 위치와 투명도가 서로 의존적인 관계를 맺고 동시에 변경되는 것입니다.
flubber를 살짝 얹으면 svg 사이의 애니메이션을 손쉽게 구현할 수 있습니다. 🔗 flubber 는 2d 형태로 구현된 문양 사이를 자연스럽게 interpolate 해주는 api를 제공합니다. flubber의 flubber.interpolate()
를 이용하면 쉽게 svg 애니메이션을 구현할 수 있습니다.
interpolation: 우리말로 옮기면 '보간'. 필자에게도 낯선 단어이긴 하지만, 위에서 살펴본 useTransform의 사례가 0~100의 값을 1,0으로 interpolate해준다고 이해하면 될 듯합니다.
motionValue
와 useTransition
, 그리고 커스텀 훅인 useFlubber
을 통해 어떻게 애니메이션을 구현했는지 살펴보겠습니다.
// 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
입니다. 이때 progress
와 fill
값은 서로 의존적입니다. progress
의 값이 0에서 4(paths
의 원소 개수와 동일합니다. 제작한 getIndex 함수는 [0, 1, 2, 3, 4]를 리턴)로 변할 때 fill
의 값은 colors의 각 원소들(hex 색상값)로 변경됩니다. useTransform
을 통해 새로운 범위의 값을 매핑해주었기 때문이죠. fill
은 motion.path 컴포넌트의 fill
속성에 매핑되어, DOM 위에서 표현되는 DOM 엘리먼트의 색상을 담당하게 됩니다.
그렇다면 모양을 결정하는 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 }),
});
}
useFlubber
는 useTransform
을 내부적으로 감싸고 있는 커스텀 훅입니다. paths.map(getIndex)
로 생성된 [0, 1, 2, 3, 4]의 값(progress
가 가질 수 있는 값)은 다섯 가지의 모양(svg)를 원소로 하는 paths
의 값과 interpolated됩니다.
핵심은 옵션으로 전달하는 mixer입니다. 문서를 확인해보면, mixer
옵션으로는 다음과 같은 함수를 전달할 수 있습니다.
mixer: (from: T, to: T) => (p: number) => any
mixer
로 전달되는 함수의 인자 a
와 b
는 연속적으로 변경되는 데이터 결과값의 쌍을 의미합니다. 여기서는 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에 대해서 더 알아보도록 하겠습니다.