가이드 모드 애니메이션 — 뿅 하고 나타나고, 뿅 하고 사라지고 싶었을 뿐인데
GuideBar에 슬라이드 애니메이션을 넣으려다 SafeArea, 키보드, 마운트/언마운트와 싸운 이야기.
가이드 모드 애니메이션 — 뿅 하고 나타나고, 뿅 하고 사라지고 싶었을 뿐인데
Fecit에는 가이드 모드가 있습니다. 할 일을 만들 때 “현재 → 기대 → 난관 → 전략” 순서로 한 단계씩 안내해주는 기능입니다. 상단 탑바 자리에 파란 GuideBar가 나타나고, 단계를 밟고, 끝나면 사라집니다.
문제는 이 GuideBar가 그냥 뚝 나타나고 뚝 사라진다는 것이었습니다.
“위에서 슬라이딩으로 내려오게 하면 예쁘겠다.”
간단한 요구사항이었습니다. 결과적으로 간단하지 않았습니다.
1단계: 나타나는 건 쉬웠다
const slideAnim = useRef(new Animated.Value(-50)).current;
useEffect(() => {
Animated.spring(slideAnim, {
toValue: 0, useNativeDriver: true, tension: 80, friction: 12
}).start();
}, []);
Animated.spring으로 위에서 톡 튀어나오는 애니메이션. 잘 됐습니다. 여기까지는 5분이었습니다.
2단계: 사라지는 건 지옥이었다
문제 1 — 컴포넌트가 먼저 죽는다
GuideBar는 guideMode ? <GuideBar /> : <RawTopBar /> 구조였습니다. X 버튼을 누르면 setGuideMode(false) → GuideBar 언마운트. 애니메이션? 실행할 시간이 없습니다.
“애니메이션 끝나고 나서 onClose 호출하면 되지 않나?”
const handleClose = () => {
Animated.timing(slideAnim, {
toValue: -50, duration: 200, useNativeDriver: true
}).start(() => onClose());
};
이론적으로 맞습니다. 하지만…
문제 2 — 한 프레임의 깜빡임
애니메이션이 끝나고 onClose() → setGuideMode(false) → GuideBar 언마운트 + SafeArea topColor 변경. 이게 같은 프레임에 일어나면서 화면이 한 순간 깜빡였습니다.
requestAnimationFrame으로 한 프레임 미뤄봤지만, 여전히 보였습니다.
문제 3 — SafeArea가 남는다
GuideBar에 SafeArea 영역을 포함시켜서 파란색이 맨 위까지 올라가게 했습니다. 그런데 슬라이드 아웃할 때 GuideBar는 올라가는데, SafeArea의 topColor는 별도 View라서 제자리에 남아있었습니다. 파란 바가 올라가도 위에 파란 잔상이.
문제 4 — -50이 부족하다
translateY: -50으로 올렸더니, SafeArea 높이(약 59px)보다 작아서 GuideBar가 완전히 사라지지 않고 SafeArea 영역에서 멈춰버렸습니다.
const slideOutDistance = -(insets.top + 50);
SafeArea 높이 + GuideBar 높이만큼 올려야 했습니다.
문제 5 — Spring이 바운스한다
Animated.spring으로 사라지게 했더니 올라가다가 살짝 내려왔다가 다시 올라갔습니다. overshootClamping: true를 줘봤지만 여전히 미세하게 멈추는 느낌이었습니다. 사라지는 데는 Animated.timing이 맞았습니다.
해결: 구조를 바꾸다
결국 근본적인 구조를 바꿨습니다.
Before: guideMode ? <GuideBar> : <RawTopBar>
GuideBar와 RawTopBar가 서로 교체되는 구조. GuideBar가 사라지면 RawTopBar가 나타나는데, 이 전환이 한 프레임에 일어나서 어색했습니다.
After: RawTopBar는 항상 렌더링, GuideBar는 absolute로 위에 겹침
{guideMode && (
<View style={{position: "absolute", top: 0, left: 0, right: 0, zIndex: 10}}>
<GuideBar ... />
</View>
)}
<RawTopBar ... />
이렇게 하면:
- GuideBar가 슬라이드 아웃할 때, 뒤에 RawTopBar가 이미 있음
- SafeArea 색 전환 문제 없음 (GuideBar가 자체적으로 SafeArea padding을 포함)
- 언마운트 타이밍 문제 없음 (애니메이션 끝난 후
onClose→setGuideMode(false))
보너스: 키보드 문제
가이드 모드에는 키보드 닫힘을 감지해서 다음 단계로 자동 이동하는 기능이 있습니다.
Keyboard.addListener("keyboardDidHide", () => {
// 다음 단계로 이동
});
Retrospect 모드에서 step 0은 만족도 피커(키보드가 아닌 모달)입니다. 그런데 키보드가 열린 상태에서 가이드를 시작하면, 키보드가 닫히면서 keyboardDidHide가 발동 → 만족도를 건너뛰고 바로 회고로.
해결은 간단했습니다:
const currentField = steps[guideStepRef.current]?.field;
if (currentField === "satisfaction") return;
만족도 단계에서는 키보드 닫힘을 무시.
교훈
-
나타나는 애니메이션은 쉽고, 사라지는 애니메이션은 어렵다. 마운트는 컴포넌트가 있으니까 애니메이션할 수 있지만, 언마운트는 컴포넌트가 사라져야 하는데 애니메이션이 아직 안 끝났습니다.
-
삼항 교체(
A ? X : Y)보다 겹침(absolute+ 항상 렌더링)이 전환에 유리하다. 두 컴포넌트가 동시에 존재할 수 있어야 자연스러운 전환이 가능합니다. -
SafeArea는 생각보다 까다롭다. 컴포넌트가 SafeArea 영역까지 차지해야 한다면, 그 영역을 컴포넌트 자체에 포함시키는 게 제어하기 쉽습니다.
-
spring은 나타날 때, timing은 사라질 때. 나타날 때는 톡 튀는 느낌이 좋지만, 사라질 때는 바운스 없이 깔끔하게.
결국 “뿅 하고 나타나고, 뿅 하고 사라지기”까지 약 한 시간이 걸렸습니다. 코드는 10줄 안팎이지만, 그 10줄을 찾기까지의 여정이 길었습니다.