이번 글에서는 React의 렌더링 라이프사이클과 Yoga(레이아웃 엔진)의 계산 결과를 중간에서 가로채어 애니메이션을 주입하는 기술을 살펴보자.
핵심은 두 가지다.
일반적인 React에서 {isVisible && <View />} 코드가 false가 되면, React는 해당 노드를 unmount하고 Fabric 렌더러에 "이 C++ Shadow Node를 즉시 제거하라"고 명령한다. 화면에서 순식간에 사라지므로 퇴장(Exit) 애니메이션을 줄 시간이 없다.
과거에는 이 문제를 우회하려고 직접 opacity를 줄인 뒤 setTimeout으로 unmount하는 식의 번거로운 패턴을 썼다.
<Animated.View exiting={...}>를 사용하면, Reanimated는 Fabric의 언마운트 명령을 중간에서 가로챈다(Intercept). React 측에서는 컴포넌트가 unmount되었지만, C++ 네이티브 단에서는 뷰를 살려두고 워클릿을 통해 퇴장 애니메이션을 끝마친 뒤에야 진짜로 뷰를 메모리에서 해제한다.
[RN 기본 애니메이션]
setState(false) → React unmount → Shadow Node 즉시 제거 → 화면에서 사라짐
(애니메이션 불가)
[Reanimated]
setState(false) → React unmount 신호 → Reanimated가 가로챔
→ 네이티브 뷰는 유지 + exiting 워클릿 실행
→ 애니메이션 종료 후 Shadow Node 제거
entering, exiting, layout선언적으로 prop만 부여하면 된다. 3가지 prop이 핵심이다.
| Prop | 시점 | 대표 프리셋 |
|---|---|---|
entering | 컴포넌트가 마운트될 때 | FadeIn, SlideInRight, ZoomIn, FadeInUp |
exiting | 컴포넌트가 언마운트될 때 | FadeOut, SlideOutLeft, ZoomOut, FadeOutDown |
layout | 형제/부모 변화로 내 위치가 변할 때 | LinearTransition, SequencedTransition, FadingTransition |
모든 프리셋은 빌더 패턴으로 세부 설정이 가능하다.
FadeInUp
.duration(300) // 0.3초 동안
.delay(100) // 0.1초 지연 후 시작
.springify() // 스프링 기반 애니메이션으로
.damping(14); // 스프링 감쇠 계수
withDelay로 리스트 아이템을 하나씩 순차적으로 등장시키는 트릭도 자주 쓰인다.
{items.map((item, index) => (
<Animated.View
key={item.id}
entering={FadeInUp.delay(index * 50)} // 50ms씩 시차를 두고 등장
/>
))}
가장 많이 쓰이는 '아이템이 삭제될 때 밑에 있던 아이템들이 스르륵 위로 올라오는' 리스트 예시이다.
import React, { useState } from 'react';
import { Text, TouchableOpacity, ScrollView, StyleSheet } from 'react-native';
import Animated, {
FadeInUp,
FadeOutDown,
LinearTransition,
} from 'react-native-reanimated';
export default function DynamicListExample() {
const [items, setItems] = useState([1, 2, 3, 4, 5]);
const removeItem = (idToRemove: number) => {
// JS 스레드에서 상태만 변경하면 끝.
// Reanimated가 exiting과 layout 애니메이션을 자동으로 처리해준다.
setItems((prev) => prev.filter((id) => id !== idToRemove));
};
return (
<ScrollView style={styles.container}>
{items.map((id) => (
<Animated.View
key={id}
// 1. 등장할 때 위에서부터 부드럽게 나타남
entering={FadeInUp.duration(300)}
// 2. 삭제될 때 아래로 스르륵 사라짐 (Fabric 언마운트 지연)
exiting={FadeOutDown.duration(300)}
// 3. 누군가 삭제되어서 내 위치가 바뀔 때,
// 뚝 끊기지 않고 새 위치로 부드럽게 이동 (Yoga 레이아웃 결과 가로채기)
layout={LinearTransition.springify().damping(14)}
style={styles.listItem}
>
<Text style={styles.text}>아이템 번호: {id}</Text>
<TouchableOpacity onPress={() => removeItem(id)}>
<Text style={styles.deleteBtn}>삭제</Text>
</TouchableOpacity>
</Animated.View>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
...
});
layout 이 유용한 이유
형제 노드가 사라지면 Yoga 엔진이 모든 자식의 새로운 좌표를 다시 계산한다.
layoutprop이 없으면 이 새 좌표가 즉시 적용되어 아이템들이 뚝 끊기듯 위로 점프한다. Reanimated는 Yoga의 이전 좌표와 새 좌표를 모두 캡처한 뒤, 그 사이를 매 프레임 보간(interpolate)해서 부드러운 이동을 만들어낸다.
목록 화면(Screen A)의 작은 썸네일을 눌렀을 때, 상세 화면(Screen B)의 큰 이미지로 이미지가 날아가며 변하는 애니메이션이다. iOS의 'Photos' 앱이나 인스타그램에서 흔히 볼 수 있는 UX다.
⚠️ 2026년 5월 현재 상태
Shared Element Transitions는 Reanimated 4.2.0(2025년 12월 출시)부터 New Architecture(Fabric)에서 다시 지원된다. 다만 다음 조건이 모두 필요하다.
react-native-reanimated4.2.0 이상react-native-screens4.19+ (native stack 필수)@react-navigation/native-stack사용ENABLE_SHARED_ELEMENT_TRANSITIONSfeature flag 활성화 필요- Reanimated 팀 공식 안내: 아직 production-ready 아님 (experimental)
Tab navigator, JS stack(
@react-navigation/stack), 투명 모달 등에서는 동작하지 않거나 제한이 있다. 실서비스 도입은 안정화 후 검토하는 것이 안전하다.
양쪽 화면의 컴포넌트에 동일한 sharedTransitionTag 문자열만 넣어주면 된다.
// Screen A (목록 화면)
<Animated.Image
source={{ uri: 'photo.jpg' }}
style={{ width: 100, height: 100 }}
sharedTransitionTag="profile-image-123" // 고유한 태그 부여
/>
// Screen B (상세 화면)
<Animated.Image
source={{ uri: 'photo.jpg' }}
style={{ width: '100%', height: 300 }}
sharedTransitionTag="profile-image-123" // 동일한 태그 매칭
/>
화면 전환이 발생하는 찰나에 Reanimated는 다음 과정을 수행한다.
sharedTransitionTag를 가진 두 컴포넌트의 절대 좌표(originX, originY)와 크기(width, height)를 캡처한다.sharedTransitionStyle로 동작 방식을 조정할 수 있다. 양쪽 화면에 동일한 스타일을 적용해야 한다.
import { SharedTransition, withSpring } from 'react-native-reanimated';
const bouncyTransition = SharedTransition.custom((values) => {
'worklet';
return {
width: withSpring(values.targetWidth),
height: withSpring(values.targetHeight),
originX: withSpring(values.targetOriginX),
originY: withSpring(values.targetOriginY),
};
});
// 양쪽 화면 모두에 적용
<Animated.Image
sharedTransitionTag="profile-image-123"
sharedTransitionStyle={bouncyTransition}
/>
기본값으로는 width, height, originX, originY, transform을 500ms withTiming으로 보간한다. 커스텀 함수는 부분적으로만 지원되고, withSpring/withTiming 기반으로만 정의 가능하다.
exiting은 무시된다자식 뷰에 exiting을 걸어두었는데 부모 뷰 전체가 조건부 렌더링으로 사라지면, 자식의 exiting 애니메이션은 실행되지 않고 즉시 파괴된다. Reanimated는 부모가 사라지는 순간의 자식 좌표를 추적할 수 없기 때문이다.
// ❌ Bad: 부모가 사라지면 자식의 exiting은 무시됨
{isOpen && (
<View>
<Animated.View exiting={FadeOut} />
</View>
)}
// ✅ Good: 자식 자체를 조건부로 렌더링
<View>
{isOpen && <Animated.View exiting={FadeOut} />}
</View>
퇴장 애니메이션을 보장하려면 사라지는 노드 자체가 Animated.View여야 한다.
key 변경은 unmount+mount다배열 인덱스를 key로 쓰면 아이템 순서가 바뀔 때 React는 이를 기존 노드 제거 + 새 노드 추가로 해석한다. layout 애니메이션이 의도대로 동작하지 않으므로, 안정적인 ID를 key로 써야 한다.
FlatList/FlashList와의 호환성가상화 리스트(virtualized list)에서는 entering/exiting이 의도대로 동작하지 않을 수 있다. 화면 밖으로 스크롤된 아이템이 unmount되면서 매번 등장 애니메이션이 재생되기 때문이다. 짧은 리스트는 ScrollView+map을, 긴 리스트는 itemLayoutAnimation prop(Reanimated가 제공하는 FlatList 래퍼)을 활용하자.
import Animated, { LinearTransition } from 'react-native-reanimated';
<Animated.FlatList
data={items}
renderItem={renderItem}
itemLayoutAnimation={LinearTransition} // 아이템 위치 변경만 애니메이트
/>
position: 'absolute'와 상성이 나쁘다절대 위치로 배치된 뷰는 Yoga의 레이아웃 흐름 바깥에 있어서 layout prop이 의도대로 작동하지 않는다. 이런 경우엔 useAnimatedStyle로 직접 transform을 다루는 게 낫다.
Reanimated는 강력하지만 만능은 아니다. 단순한 페이드인/아웃 정도라면 RN 내장 Animated API나 LayoutAnimation으로도 충분하고, 더 복잡한 물리 시뮬레이션이나 SVG 모핑은 react-native-skia나 react-native-svg 같은 도구를 함께 쓰는 게 낫다. 도구의 선택지를 넓혀두고, 상황에 맞는 도구를 고르는 안목이 결국 좋은 애니메이션을 만드는 핵심이다.