2025.05.08
이번 글은 React Native Reanimated 의 exiting 애니메이션을 사용하면서 만난 에러를 해결하는 과정을 담았어요.
구현한 애니메이션은 리스트 확장, 축소 애니메이션이에요. 화면에 표시된 "5km 30분 내 주파 달성" 이라는 항목을 터치하면, 그 아래 항목들이 위에서 아래로 부드럽게 펼쳐집니다. 다시 터치하면 항목들이 아래에서 위로 자연스럽게 접힙니다.
위 애니메이션을 구현하면서 많은 시행착오를 겪었습니다. 특히, 중첩 레이아웃 구조에서 부모 컴포넌트가 먼저 umount 될 경우 exiting 애니메이션이 동작하지 않는 문제가 많이 발생했어요. 이번 글에선 이 문제에 대해 깊게 파고 들어가볼 겁니다. React Native 의 렌더링 로직 원리, Reanimated 의 애니메이션 처리 방식 등에 대해 정확히 이해할 수 있을 거에요.
영상을 보면 리스트가 확장될 때는 애니메이션이 자연스럽게 실행되지만, 축소될 때는 애니메이션이 동작하지 않는 것을 볼 수 있어요. 자연스럽게 축소되지 않고 바로 끊겨버리죠.
아래는 위 영상의 애니메이션 코드입니다.
export function ListSection({
items,
isExpanded,
onToggle,
toggleKey,
suffixText,
styles,
}: ListSectionProps) {
if (items.length === 0) return null;
const expandHeight = (values: EntryAnimationsValues) => {
'worklet';
const finalH = values.targetHeight;
return {
initialValues: { height: 0 },
animations: {
height: withTiming(finalH, { duration: 500 }),
},
};
};
const shrinkHeight = (values: EntryAnimationsValues) => {
'worklet';
return {
animations: {
height: withTiming(0, { duration: 500 }),
},
};
};
return (
<Animated.View
layout={LinearTransition.springify().duration(1000)}
style={styles.lineContainer}
>
<TouchableOpacity onPress={() => onToggle(toggleKey)}>
<Text numberOfLines={1} style={[styles.text, styles.highlight]}>
{items[0]}
</Text>
</TouchableOpacity>
{isExpanded && (
<Animated.View
entering={expandHeight}
exiting={shrinkHeight}
style={[
styles.dropdownContainer,
{ overflow: 'hidden', backgroundColor: 'red' },
]}
>
{items.map((el, i) => (
<Animated.Text
entering={FadeIn.springify().delay(100 * (i + 1))}
key={`${el}-${i}`}
style={styles.dropdownItem}
>
{el}
</Animated.Text>
))}
</Animated.View>
)}
<Animated.Text
layout={LinearTransition.springify().duration(1000)}
style={styles.text}
>
{suffixText}
</Animated.Text>
</Animated.View>
);
}
expandHeight
는 드롭다운을 확장하는 애니메이션, shrinkHeight
는 축소하는 애니메이션이에요. 둘 다 커스텀 애니메이션 함수로 정의했습니다.
아래는 핵심이 되는 드롭다운 영역 코드에요. 부모컴포넌트인 Animated.View
가 전체 아이템을 감싸고, 자식컴포넌트인 Animated.Text
가 각각의 아이템을 나타냅니다.
{
isExpanded && (
<Animated.View
entering={expandHeight}
exiting={shrinkHeight}
style={[
styles.dropdownContainer,
{ overflow: 'hidden', backgroundColor: 'red' },
]}
>
{items.map((el, i) => (
<Animated.Text
entering={FadeIn.springify().delay(100 * (i + 1))}
key={`${el}-${i}`}
style={styles.dropdownItem}
>
{el}
</Animated.Text>
))}
</Animated.View>
);
}
isExpanded=true
가 되면 Animated.View
가 mount 돼요. 이때 entering
으로 정의한 expandHeight
애니메이션이 실행됩니다. 반대로 isExpanded=false
로 바뀌면 뷰가 unmount 되면서 exiting
으로 등록한 shrinkHeight
가 실행될 거라 기대했어요.
하지만 예상과는 달리 shrinkHeight
는 실행되지 않고 다음과 같은 경고를 띄웠습니다.
해당 경고는 보통 레이아웃 사이즈나 위치를 계산하는데 실패했을 경우 발생해요. 좀 더 구체적으로는,
즉, 레이아웃이 충돌했거나 존재하지 않은 레이아웃에 대해 measure 했기 때문이라고 볼 수 있어요.
measure 은 컴포넌트의 레이아웃 변경 시, 해당 컴포넌트의 레이아웃 정보(위치, 크기 등)을 계산하는 작업입니다.
다시 천천히 짚어봤어요. exiting 애니메이션은 컴포넌트가 unmount 될 때 발생합니다. 즉, unmount 된 컴포넌트는 React Native 가 Yoga tree 에서 즉시 제외시킵니다. 이때 해당 컴포넌트에 exiting 이 있다면 그 애니메이션은 Reanimated 가 자체적으로 관리해요. (참고: Reanimated Discussion #3596)
즉, Yoga tree 는 컴포넌트는 즉시 제거하지만, Reanimated 는 exiting 애니메이션을 위해 해당 컴포넌트를 독립적으로 처리하고 있습니다. 이처럼 React Native 와 Reanimated 의 처리 시점이 분리되어 있다는 점이 중요해요.
여기서 중요한 사실이 있어요. height, width, position 같은 속성은 모두 layout props 에 해당하며, 이 값들은 Yoga tree 를 기반으로 계산된다는 것이에요. (참고: Reanimated Discussion #3211)
Yoga tree 는 렌더링 시, 컴포넌트의 위치와 크기를 계산하고 관리하는 레이아웃 시스템입니다.
이건 layout props 들을 withTiming, withSpring 같은 애니메이션 함수와 함께 사용할 때도 마찬가지에요 . 애니메이션 프레임마다 Yoga tree 에서 값을 계산하게 되죠. 즉, 애니메이션 프레임마다 layout recalculation 이 일어납니다.
이걸 제 상황에 대입해볼게요. Text
자식 컴포넌트는 부모의 exiting
애니메이션이 실행될 때 부모와 함께 layout recalculation 을 수행합니다.
문제는 부모 컴포넌트가 이미 unmount 되어 Yoga Tree 에서 제거된 상태라는 점이에요. 그 상태에서 자식 컴포넌트가 layout recalculation 을 위해 부모의 layout 정보를 참조하려 하다 보니 제대로 받아오지 못하고 에러가 발생한 것이죠.
결국 앞서 확인했던 경고 메시지도 이 맥락에서 이해할 수 있어요. 컴포넌트가 완전히 mount 되지 않은 상태, 즉 부모가 이미 unmount 된 상황에서 measure 를 시도했기 때문에 에러가 발생한 겁니다.
문제를 해결하기 위해 부모 컴포넌트가 Yoga tree 에서 제거되지 않도록 컴포넌트 구조를 변경했습니다. 구체적으로는 Animated.View 를 조건문 바깥으로 빼내 항상 렌더링된 상태를 유지시켰어요.
처음에는 exiting 애니메이션을 활용하는 방향으로 접근했지만, 위에서 보았듯이 Reanimated 가 layout 정보를 참조할 수 없다는 구조적인 한계가 있었습니다.
그래서 exiting 을 제거하고, layout 애니메이션만으로 동작을 구현하는 방식으로 방향을 바꿨어요. 이 구조에서는 부모 컴포넌트가 트리에서 제거되지 않기 때문에, layout 정보가 제거되지 않고 애니메이션도 정상적으로 작동합니다.
애니메이션의 디테일은 LinearTransition 을 통해 조절했고, 의도한 애니메이션을 구현할 수 있었어요.
<Animated.View
layout={LinearTransition.duration(500)}
style={[
styles.dropdownContainer,
{ overflow: 'hidden', backgroundColor: 'red' },
]}
>
{isExpanded && (
<View>
{items.map((el, i) => (
<Animated.Text
entering={FadeIn.springify().delay(100 * (i + 1))}
exiting={FadeOut.springify()}
key={`${el}-${i}`}
style={styles.dropdownItem}
>
{el}
</Animated.Text>
))}
</View>
)}
</Animated.View>
만약 저와 동일한 상황에서 exiting 애니메이션을 반드시 유지시켜야 한다면 Animated.Text 의 위치와 height 를 명시적으로 설정하는 방법이 있습니다. 하지만 이는 연산량이 증가하고 상태 관리가 복잡해진다는 단점이 있어요.
제 경우엔 LinearTransition
과 Animated.Text
만으로도 의도한 애니메이션을 충분히 구현가능했기에 제외했습니다.