zoe

키보드가 두 번 올라오던 자리

아이두 에 메모 기능을 붙이면서, 작성 화면에 들어가자마자 바로 글을 쓸 수 있도록 autoFocus 를 걸어두었다. 그런데 화면이 떠오르기도 전에 키보드가 한 번 올라오고, 화면이 완전히 자리 잡은 뒤에 다시 한 번 올라왔다. 한 번이면 충분한 자리에 두 번이 찍히고 있었다.

오류 영상

페이지 진입 시 fade_from_bottom 애니메이션이 200ms 동안 돈다. 키보드는 이 애니메이션이 끝난 다음에 한 번만 올라와야 하는 자리였다.

첫 번째 시도: setTimeout

가장 먼저 손이 간 건 autoFocus 대신 타이밍을 직접 잡아주는 일이었다.

표면적으로는 잘 동작했다. 다만 두 가지가 마음에 걸렸다.

하나는 저사양 기기에서의 프레임 드랍. animationDuration 이 200ms 라고 해도 실제 렌더링이 그보다 더 길어질 수 있다. 그 경우 애니메이션이 끝나기 전에 focus() 가 호출되며 키보드가 다시 두 번 올라온다.

다른 하나는 매직 넘버 300. 나중에 코드를 다시 열어볼 때 "왜 하필 300인가" 라는 질문이 남는다.

타이밍을 시간으로 풀지 말고, 끝난 시점 자체를 받아오는 방향으로 가야 했다.

두 번째 시도: transitionEnd

고민을 하자 팀원이 transitionEnd 이벤트를 제안해주었다. navigation.addListener('transitionEnd', ...) 은 화면 전환 애니메이션이 끝난 시점에 콜백을 실행한다.

if (!e.data.closing) 조건으로 push 로 들어왔을 때만 focus() 가 호출되도록 두고, 뒤로 가는 흐름에서는 무시했다.

전환 흐름은 이렇게 흘러가야 했다.

  1. 화면 push/pop 시작
  2. fade_from_bottom 애니메이션 실행
  3. 애니메이션 완료
  4. transitionEnd 발화 → focus()

첫 진입에서는 키보드가 한 번만 올라왔다. 그런데 두 번째 진입부터 다시 두 번 올라오기 시작했다.

코드가 아니라 구조의 문제였다

코드 자체에는 손댈 곳이 없었다. 문제는 useNavigation() 이 반환하는 네비게이터가 어느 Stack 인가였다.

파일 구조는 두 Stack 으로 겹쳐 있었다. create.tsx 에서 useNavigation() 을 부르면 가장 가까운 네비게이터, 곧 Stack B 의 navigation 이 돌아온다. 그런데 실제로 fade_from_bottom 을 도는 건 Stack A 였다. 내가 구독하고 있던 transitionEnd 는 Stack A 가 아니라 Stack B 의 것이었다.

Stack A 와 Stack B 의 transitionEnd 발화 시점을 로그로 찍어봤다.

transitionEnd 발화 순서 로그
첫 진입에서는 Stack A → Stack B 순서지만, 재진입부터는 Stack B → Stack A 순서로 바뀐다

첫 진입에서는 Stack A → Stack B 순서로 나오지만, 재진입부터는 Stack B → Stack A 로 뒤집힌다.

순서가 보장되지 않는 이유는 expo-router 가 내부적으로 @react-navigation/native-stack 을 쓰기 때문이다. Stack Navigator 의 애니메이션은 JS 스레드가 아니라 네이티브(iOS UIKit / Android Fragment) 에서 처리된다. 중첩 Stack 에서는 각 Stack 이 독립된 네이티브 컨트롤러를 갖기 때문에, transitionEnd 발화 순서를 JS 쪽에서 가정할 수 없다.

Stack B 의 transitionEnd 에서 focus() 를 호출하면, 재진입 때 Stack A 의 애니메이션이 아직 끝나지 않은 시점에 키보드가 올라온다. 키보드가 두 번 올라오던 정체였다.

세 번째 시도: Stack B 에서 애니메이션을 처리한다면

원인을 알고 나서 처음 떠올린 건 Stack B 에서 애니메이션을 처리하고, Stack A 의 애니메이션을 없애는 방향이었다. 그러나 fade_from_bottom 은 원래부터 Stack A 의 애니메이션이었고, Stack B 의 create.tsx 는 Stack B 가 마운트될 때 초기 화면으로 렌더링되는 자리였다. react-native-screens 는 이 경우를 push 전환으로 인식하지 않아 처음부터 애니메이션을 스킵하고 있었다.

  • iOS: firstTimePush 이면 animated: NO
  • Android: topScreenWrapper 가 null 이면 stackAnimation = NONE

Stack B 는 어차피 스킵 상태이므로, Stack A 의 애니메이션까지 없애면 결국 화면 전환에서 애니메이션이 통째로 사라진다. 의도와 반대로 가는 길이었다.

InteractionManager.runAfterInteractions 도 시도해봤다. InteractionManager 는 JS 측 인터랙션만 추적하는데, Stack 의 네이티브 애니메이션은 거기에 등록되지 않는다. 타이밍이 맞지 않았다.

마지막 해결: Stack A 의 transitionEnd 를 직접 구독

해야 할 일이 명확해졌다. useNavigation() 이 잡아준 Stack B 가 아니라, 실제로 애니메이션을 도는 Stack A 의 transitionEnd 를 받아야 한다. navigation.getParent() 로 한 단계 위 Stack 의 navigation 을 가져왔다.

키보드는 다시 한 번만 올라왔다. 그런데 이번엔 타입 에러가 떴다.

타입은 비어 있는 EventMap 에서 막혔다

transitionEnd@react-navigation/native-stackNativeStackNavigationEventMap 에 정의된 이벤트다.

expo-router 의 useNavigation() 은 Stack, Tab, Drawer 어디에서나 쓸 수 있어야 해서, 특정 네비게이터의 EventMap 을 포함하지 않는다.

EventMap 이 비어 있으니 TypeScript 는 addListener('transitionEnd') 를 인식하지 못하고 에러를 낸다.

getParent<T>() 의 제네릭에 필요한 shape 만 직접 명시해 좁혔다.

NativeStackNavigationEventMap 을 직접 import 하거나 as any, @ts-expect-error 를 쓰지 않고, react-navigation 이 의도한 제네릭 방식으로 타입을 맞췄다. (@react-navigation/native-stack 은 expo-router 의 간접 의존성이라 직접 import 하면 버전이 어긋날 위험이 있다.)

같은 패턴이 또 나올 자리

해결하고 나서 더 오래 남은 건 한 줄짜리 사실이었다. 중첩 Stack 에서 useNavigation() 이 가리키는 건 가장 가까운 Stack 이고, transitionEnd 발화 순서는 JS 쪽에서 가정할 수 없다. 키보드 타이밍뿐 아니라, 화면 전환이 끝난 시점을 기준 삼아 무언가를 트리거하는 어떤 코드든 같은 함정에 빠질 수 있는 자리다.

JS 의 setTimeout 으로 타이밍을 짜는 방향은 결국 네이티브 애니메이션의 변동을 따라잡지 못한다. 시간이 아니라 끝난 시점을 직접 받아오는 쪽, 그리고 그 시점이 어느 Stack 에서 발화하는지를 먼저 확인하는 쪽이 안전하다.

다시 만든다면

처음에 setTimeout 으로 풀려 했던 자리에서 한 번 더 멈춰 섰어야 했다. 이 타이밍을 만들어내는 주체가 누구인가. 그게 JS 라면 setTimeout 으로 가도 되고, 네이티브 애니메이션이라면 그쪽의 이벤트를 받는다. 그리고 그 이벤트를 어디서 받아야 하는지 — useNavigation() 이 어느 Stack 을 가리키는지 — 를 먼저 확인하는 것. 다음에 비슷한 자리에서 시작점이 되어줄 질문이다.