스크롤을 못 따라오던 sticky 헤더 — UI 스레드로 옮기기
긴 태스크 폼에 sticky 필드 헤더를 붙였다. 잘 동작했는데, fling을 하면 얼었다가 점프했다. 답은 로직을 더 짜는 게 아니라, 스크롤을 JS 스레드에서 빼는 것이었다.
스크롤을 못 따라오던 sticky 헤더 — UI 스레드로 옮기기
Fecit의 태스크는 길어질 수 있습니다. 설명, 세부, 의도, 준비물, 회고, 첨부 — 각각이 접히는 섹션이고, 섹션마다 필드가 들어 있죠. 한참 스크롤하다 보면 지금 내가 어느 섹션에 있는지조차 놓칩니다. 그래서 2단 sticky 헤더를 붙였습니다. 현재 섹션 헤더가 상단에 고정되고, 그 바로 아래에 지금 편집 중인 필드 라벨이 고정되는 식으로요.
잘 됐습니다. 그런데 “느낌”이 안 맞았고, 그 느낌을 잡는 데 세 번이 걸렸습니다.
첫 번째: 상단 위로 떠오르는 헤더
다음 섹션으로 스크롤하면 현재 sticky 헤더는 위로 밀려 올라가며 사라져야 합니다. 그런데 사라질 자리를 지나, 스크롤 영역 위의 고정된 타이틀 바를 덮으며 떠올랐습니다.
오버레이가 position: absolute; top: 0인데 부모의 overflow가 기본값(visible)이었습니다. push-up 애니메이션이 오프셋을 음수로 밀면, 경계 위로 그냥 계속 그려진 거죠. 한 줄로 해결됐습니다. 오버레이를 클리핑 컨테이너로 감싸 위쪽 경계를 넘는 건 잘라냅니다.
clip: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, overflow: "hidden" },
이제 헤더가 경계선 아래로 들어가며 사라집니다. 좋아요.
두 번째: 미끄러지지 않고 툭 꺼지는 필드 라벨
다음 지적: 섹션과 필드 라벨이 같이 밀려 올라가다 필드 라벨이 사라지는 순간이 어색하다. 살짝 덜컥거린다.
오버레이의 위치는 Reanimated shared value가 구동합니다(동기). 그런데 오버레이의 내용 — 어떤 라벨을 그릴지 — 는 React state입니다(비동기, 한 프레임 뒤에 반영). 교체 경계에서 위치는 새 정착 지점으로 즉시 점프하는데, 화면엔 아직 옛 라벨이 한 프레임 남아 있습니다. 그래서 옛 라벨이 새 위치에 잠깐 찍히고, 그게 덜컥거림이었습니다.
해결의 실마리: 교체가 일어나는 바로 그 순간, 스크롤 본문의 진짜 헤더가 sticky 헤더와 정확히 같은 자리에 있습니다. 그러니 내용이 따라잡을 때까지 그 한 프레임만 오버레이를 숨기면, 아래에 있던 진짜 요소가 그대로 비쳐서 이음매 없이 이어집니다.
실제로 commit된 내용 id를 따로 추적하고, 거기에 opacity를 묶습니다 — 그리려는 라벨과 화면에 그려진 라벨이 일치할 때만 보이게요.
const sectionReady = activeSecId === null || renderedSecSV.value === activeSecId;
sectionOpacity.value = (showSection && sectionReady) ? 1 : 0;
더 부드러워졌습니다. 그런데도 — 그런데도 — 매끄럽지 않았습니다. 그리고 여기서 하마터면 세 번째로 추측할 뻔했습니다.
세 번째: 추측 그만, 측정
이미 감으로 두 번 고쳤습니다. 정직한 수순은 추측이 아니라 로그였습니다. 그래서 스크롤 cadence를 찍었습니다. 매 스크롤 갱신 사이의 시간 간격과 픽셀 간격을요.
느린 드래그에선 간격이 ~16ms로 일정했습니다. 괜찮네요. 그런데 fling에선 이랬습니다.
dt=318ms dy=2.3px ← JS 스레드가 318ms 멈춤
dt=6ms dy=8.3px
dt=0ms dy=18.3px
dt=1ms dy=27.7px
dt=0ms dy=36.3px ← 쌓여 있던 이벤트가 한꺼번에 처리됨
바로 이거였습니다. sticky 위치를 onScroll로 구동하고 있었는데, 이건 JS 스레드에서 돕니다. fling 동안 JS 스레드가 멈추면, 네이티브 스크롤은 매끄럽게 계속 움직이고, 큐에 쌓인 스크롤 이벤트가 버스트로 JS에 몰려서 처리됩니다. 그래서 sticky 오버레이가 얼었다가 모든 위치를 한 번에 텔레포트합니다. 본문은 유리처럼 흐르는데 그 옆에서 오버레이만 끊긴 거죠. transition 로직을 아무리 만져도 안 됩니다. 입력이 너무 늦게 도착하니까요.
Reanimated가 존재하는 이유가 정확히 이겁니다.
해법: 계산을 UI 스레드에서
위치 계산 전체를 useDerivedValue worklet으로 옮겼습니다. 스크롤 오프셋을 shared value로 읽어, 매 프레임 UI 스레드에서 스크롤에 프레임-락으로 따라가며 돕니다. 여전히 JS 스레드로 넘어가는 건 내용 교체뿐인데, 그건 드물고 두 번째 단계의 readiness 게이트로 가려집니다.
useDerivedValue(() => {
const y = scrollSV.value; // UI 스레드 스크롤 오프셋
const c = cfg.value; // 섹션/필드 좌표, 가끔 미러링
// …활성 섹션·필드, push-up 오프셋 계산…
sectionTY.value = groupPush;
fieldTY.value = baseTop + groupPush + fieldOwnPush;
sectionOpacity.value = (showSection && sectionReady) ? 1 : 0;
if (activeSecId !== reqSecSV.value) runOnJS(setSectionContentId)(activeSecId);
});
핵심은 매 프레임 바뀌는 값과 정적인 설정을 분리한 것입니다. 스크롤 오프셋은 초당 60번 바뀝니다 — 그게 worklet의 일이고요. stop 좌표(각 섹션·필드가 있는 위치)는 레이아웃이나 접힘/펼침 때만 바뀌니, 가끔 shared value로 미러링해 worklet이 읽게 둡니다. 매 프레임은 가볍게, 그러면서 정확하게.
UI 스레드 스크롤 오프셋을 얻는 방법은 컨테이너마다 달랐습니다.
- 일반
ScrollView→useScrollViewOffset(animatedRef)가 shared value를 거저 줍니다. DraggableFlatList(서브태스크 리스트)은 스크롤 핸들러를 노출하지 않지만, 내부 shared value에 스크롤 오프셋을 들고 있습니다 — 그리고onAnimValInit콜백으로 그걸 꺼내 줍니다. 거기서 받아 넘겼습니다.
화면 13개, 공유 컴포넌트 하나, 같은 숫자를 얻는 두 가지 방법.
교훈
-
애니메이션은 UI 스레드에서. JS 스레드
onScroll로 구동하는 건 부하가 걸리면 아무리 계산이 깔끔해도 늦고 끊깁니다. 본문은 네이티브로 흐르니, 오버레이도 같은 자리에 살아야 합니다. -
이미 한 번 추측해서 틀렸으면, 멈추고 측정하라. transition 로직을 두 번 고친 뒤에야, cadence 로그 한 줄이 진짜 원인을 단번에 보여줬습니다. 로그가 두 번째 추측보다 빨랐습니다.
-
뒤에 똑같은 게 있으면 이음매는 안 보인다. 교체 순간 오버레이를 한 프레임 숨기는 게 통하는 건, 아래에 진짜 헤더가 정확히 거기 있기 때문입니다. 왜 안전한지를 아는 것이 땜질과 해결의 차이입니다.
-
매 프레임 바뀌는 것과 가끔 바뀌는 것을 나눠라. 매 프레임: 스크롤 위치. 가끔: 레이아웃. 둘을 분리하면 hot path가 작게 유지됩니다.
sticky 헤더는 잘 되면 아무도 눈치채지 못합니다. 끊기는 순간에만 그것만 보이죠. 매끄러움은, 많은 UX가 그렇듯, 없을 때에만 느껴집니다.