들어가기 전에
지난 글에서 Shared Value의 구조를 정리했습니다. 그럼 애니메이션은 항상 Shared Value로 만들어야 할까요. 버튼 하나 페이드인 시키는 데도 useSharedValue와 useAnimatedStyle을 꺼내야 하는지 생각해 볼 만합니다.
이 글은 Reanimated 4 / React Native New Architecture 기준입니다. React Native에 내장된 Animated API는 다루지 않습니다.
선택지는 세 갈래다
먼저 층위를 정리하는 게 좋겠습니다. 셋이 나란히 놓이는 게 아닙니다.
react-native-reanimated
├─ ① CSS Transitions / Animations ← 선언적. 결과만 적는다
└─ ② Shared Value + Worklet ← 명령적. 값을 직접 굴린다
③ react-native-ease ← 별도 라이브러리. 플랫폼에 맡긴다
①과 ②는 모두 Reanimated가 제공합니다. 이름이 CSS라서 웹 것이거나 별도 라이브러리처럼 보이지만 아닙니다.
① 선언적으로: CSS Transitions와 Animations
웹의 transition과 @keyframes를 스타일 객체로 옮겨 놓은 것입니다. 애니메이션을 굴리는 대신 원하는 결과를 적어 둡니다.
값이 바뀔 때: Transitions
지켜보던 값이 바뀌면 그 사이를 채웁니다.
const [expanded, setExpanded] = useState(false);
return (
<Animated.View
style={{
width: expanded ? 260 : 120,
backgroundColor: expanded ? "#fa7f7c" : "#87cce8",
transitionProperty: ["width", "backgroundColor"],
transitionDuration: 500,
}}
/>
);애니메이션 코드가 없습니다. setExpanded로 state를 바꾸면 width와 backgroundColor가 새 값을 받고, 그 사이를 Reanimated가 채웁니다. 지켜볼 속성을 transitionProperty에 적어 두는 것이 전부입니다.
타임라인을 재생: Animations
이쪽은 keyframe을 정해 두고 재생합니다.
const pulse = {
from: { transform: [{ scale: 0.8 }] },
to: { transform: [{ scale: 1.2 }] },
};
<Animated.View
style={{
animationName: pulse,
animationDuration: "1s",
animationIterationCount: "infinite",
}}
/>;state도 버튼도 없습니다. 마운트되면 스스로 시작합니다. 웹과 달리 전역 스타일시트가 없어서 이름 대신 keyframes 객체를 그대로 넘깁니다.
| 언제 시작하나 | 성격 | |
|---|---|---|
| Transitions | 값이 바뀔 때 | A에서 B로 한 번 |
| Animations | 마운트되면 스스로 | 타임라인, 반복 가능 |
공식 문서가 여기서 시작하라는 이유
공식 문서는 이쪽을 기본 출발점으로 잡습니다. 검토해 볼 만한 선택지 정도가 아닙니다.
"This covers most everyday UI animations, and it's the recommended place to start."
— CSS Transitions
이유도 밝혀 두었습니다. 훅과 Shared Value를 엮을 필요가 없어 코드가 짧고, JS 스레드 밖 Reanimated 코어에서 돌아갑니다. 무엇이 움직이는지 라이브러리가 미리 압니다. 워클릿은 실행 전까지 무엇을 반환할지 알 수 없지만 선언적 API는 "opacity가 500ms 동안 0에서 1로 간다"를 처음부터 알고 있습니다. 최적화 여지가 여기서 생깁니다.
섞어 쓸 때 걸리는 것
Transition은 리렌더로 값이 바뀔 때만 발동합니다.
"Passing an inline shared value won't trigger a transition, because updating a shared value doesn't re-render the component."
— CSS Transitions
Shared Value를 transitionProperty 대상에 물려도 아무 일이 일어나지 않습니다. 두 방식을 섞어 쓰다 걸리기 쉬운 자리입니다.
② 직접 제어하기: Shared Value와 Worklet
프레임 단위로 값을 다뤄야 할 때 씁니다.
const progress = useSharedValue(0);
const style = useAnimatedStyle(() => ({
opacity: progress.value,
transform: [{ translateY: (1 - progress.value) * 12 }],
}));자유도가 이 방식의 값어치입니다. 제스처 입력, 스크롤 오프셋, 보간, 파생값, 스프링 물리를 직접 다룰 수 있습니다.
다음 중 하나라도 해당하면 이쪽입니다.
- pan, drag, pinch, swipe처럼 제스처를 따라가야 한다
- 스크롤 위치에 따라 헤더나 캐러셀이 연동되어야 한다
interpolate나useDerivedValue같은 계산이 필요하다- 애니메이션 중간값을 다른 로직에서 읽어야 한다
- cancel, sequence, repeat 같은 세밀한 제어가 필요하다
- layout animation이나 entering/exiting이 필요하다
progress.value = ... 같은 변경을 컴포넌트 렌더 중에 실행하면 안 됩니다. 이벤트 핸들러나 useEffect, 제스처 워클릿처럼 렌더 바깥에서 바꿔야 합니다.
③ 플랫폼에 넘기기: react-native-ease
Reanimated 밖의 선택지입니다. react-native-ease는 EaseView에 animate와 transition을 넘기는 방식입니다.
import { EaseView } from "react-native-ease";
<EaseView
animate={{ opacity: visible ? 1 : 0 }}
transition={{ type: "timing", duration: 300 }}
/>;모양은 CSS Transitions와 비슷하지만 누가 애니메이션을 굴리느냐가 다릅니다. JS 애니메이션 루프도, 워클릿도, Shared Value도 쓰지 않고 플랫폼 네이티브 API에 맡깁니다. iOS는 CAAnimation 계열, Android는 ObjectAnimator와 SpringAnimation입니다.
범용 대체재는 아닙니다.
"Layout animations — Animating width/height/layout changes is not supported."
"Old architecture — Fabric (new architecture) only."
— react-native-ease
레이아웃을 바꾸는 애니메이션이 안 되고 New Architecture에서만 동작합니다. 제스처 연동이나 복잡한 체이닝도 Reanimated의 영역입니다.
이득은 뷰가 많아질수록 커집니다. 만든 쪽이 공개한 측정값입니다.
| 뷰 개수 | react-native-ease | Reanimated CSS | Shared Value |
|---|---|---|---|
| 10개 | 0.21 | 0.99 | 1.15 |
| 500개 | 0.60 | 5.50 | 8.31 |
Android 릴리스 빌드에서 프레임당 UI 스레드 시간(ms)이고 낮을수록 좋습니다. 10개일 때는 차이가 크지 않지만 500개에서 벌어집니다. 라이브러리를 만든 쪽 측정이고 에뮬레이터 환경이라는 점은 감안해야 합니다. 자세한 수치와 iOS 결과는 저장소 벤치마크에 있습니다.
네이티브 API로 넘긴다고 비용이 사라지는 건 아닙니다. 워클릿과 Shared Value 부담은 덜지만 네이티브 뷰 렌더링과 컴포지팅 비용은 그대로입니다.
무엇을 움직이는지도 본다
어떤 도구를 고르든 움직이는 속성이 무엇인지가 비용을 가릅니다.
| 구분 | 대표 속성 | 바꾸면 벌어지는 일 |
|---|---|---|
| non-layout | opacity, transform, backgroundColor, borderRadius | 그 뷰만 다시 그린다 |
| layout | width, height, top, margin, flex | 주변 요소의 배치까지 다시 계산한다 |
transform과 top은 눈에 똑같아 보여도 이만큼 다릅니다. 애니메이션에서는 차이가 더 벌어집니다. 60fps면 16.67ms마다 새 화면을 만들어야 하는데, 그때마다 배치를 다시 계산하면 프레임 예산을 넘기기 쉽습니다.
같은 움직임이라면 non-layout으로 바꿔 볼 만합니다.
| 하려던 것 | 대신 |
|---|---|
width 늘리기 | scaleX |
top으로 옮기기 | translateY |
| 나타났다 사라지기 | 요소를 넣고 빼는 대신 opacity와 transform |
플랫폼에 맡기는 react-native-ease가 레이아웃 애니메이션을 지원하지 않는 것도 같은 맥락입니다. 네이티브 애니메이션 API가 건드리는 건 iOS CALayer의 키 패스와 Android View의 속성입니다. width나 height처럼 레이아웃을 다시 계산해야 하는 값은 애초에 그 대상이 아닙니다.
그래서 무엇을 쓸까
| 이럴 때 | 선택 |
|---|---|
| 손가락을 따라 실시간으로 움직여야 한다 (드래그, 스와이프, 스크롤 연동) | Shared Value + Worklet |
| 진행 중인 값을 읽거나, 도중에 멈추거나, 여러 개를 차례로 실행해야 한다 | Shared Value + Worklet |
| 상태가 바뀌면 알아서 전환되면 된다 (열림과 닫힘, 나타남과 사라짐, 스피너) | CSS Transitions / Animations |
| 위 경우인데 레이아웃을 바꾸지 않고, 같은 전환이 수많은 뷰에서 반복된다 | react-native-ease 검토 |
참고 문서
- https://docs.swmansion.com/react-native-reanimated (Reanimated 공식 문서)
- https://docs.swmansion.com/react-native-reanimated/docs/css-transitions/overview
- https://docs.swmansion.com/react-native-reanimated/docs/css-animations/overview
- https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue
- https://docs.swmansion.com/react-native-reanimated/docs/guides/performance
- https://github.com/AppAndFlow/react-native-ease