Skip to main content
← 블로그

행마다 제스처를 달았다가, 하나로 합친 이야기

VauDium·

폴더를 드래그해서 순서를 바꾸고 다른 폴더 안에 넣기. 행마다 Pan 제스처를 달았다가 스크롤과 싸우고, 결국 목록 전체를 감싼 제스처 하나로 합친 과정.

행마다 제스처를 달았다가, 하나로 합친 이야기

Fecit에 폴더를 만들었습니다. 꼬리표가 가벼운 분류라면, 폴더는 트리입니다. 할 일도, 메모도, 템플릿도 폴더에 담아 정리할 수 있습니다.

폴더가 생기면 자연히 두 가지를 하고 싶어집니다. 순서 바꾸기폴더 안에 넣기. 둘 다 드래그로 하고 싶었습니다. 위아래로 끌면 순서가 바뀌고, 다른 폴더 위에 떨어뜨리면 그 안으로 들어가는. 손가락 하나로 끝나는 동작.

만들고 보니, 또 스크롤과 싸우게 됐습니다.

왜 또 어렵냐면

전에 타임테이블에서 드래그 중 자동 스크롤을 구현하면서 한 번 데인 적이 있습니다. 그때 배운 건 “JS 스레드와 UI 스레드를 섞지 마라”였습니다. 이번엔 다른 종류의 충돌이었습니다.

폴더 목록은 세로로 스크롤됩니다. 그런데 폴더를 드래그하는 동작도 세로입니다. 세로 스크롤 안에서 세로 드래그. 손가락이 위로 움직였을 때, 이게 스크롤인지 드래그인지 누가 정하느냐가 문제의 전부였습니다.

그리고 드래그를 시작하는 순간, 스크롤은 멈춰야 합니다. 안 그러면 폴더를 끌어올리는데 목록이 같이 따라 올라가서 아무것도 안 됩니다. “드래그하는 동안 스크롤을 끈다.” 이게 나중에 발목을 잡습니다.

첫 번째 구조: 행마다 제스처를 달다

직관적인 출발점은 이거였습니다. 폴더 행 하나하나에 Pan 제스처를 달기.

각 행이 자기 제스처를 가지면 “어느 행이 잡혔는지” 고민할 필요가 없습니다. 잡힌 그 행이 잡힌 겁니다. 길게 누르면(220ms) 무장되고, 그 뒤로 움직이면 드래그가 시작되고, 시작되면 스크롤을 끕니다.

function FolderRow({folder, ...}) {
    const rowGesture = useMemo(() => Gesture.Pan()
        .manualActivation(true)
        .simultaneousWithExternalGesture(scrollRef)
        // 220ms 무장 → 활성화 → scrollEnabled = false
    , [...]);
    return <GestureDetector gesture={rowGesture}>...</GestureDetector>;
}

여기까지는 잘 됐습니다. 드래그도 되고, 폴더 안에 넣는 것도 됐습니다. 그런데 두 번째 동작부터 이상했습니다.

한 번 드래그한 다음, 다시 드래그하려면 두 번 눌러야 했습니다. 첫 시도는 그냥 씹히고, 두 번째에야 잡혔습니다. 끌어보면 깜빡이고 끊겼습니다.

진단: 스크롤을 끄면 자기 제스처가 죽는다

원인은 “드래그하는 동안 스크롤을 끈다”에 있었습니다.

행에 달린 제스처는 스크롤뷰 안에 있습니다. 드래그가 시작돼서 scrollEnabled = false를 호출하면, 스크롤뷰가 자식들의 터치 처리를 재조정합니다. 그 과정에서 방금 활성화된 그 행의 제스처가 같이 취소됩니다. 자기가 켠 스위치에 자기가 맞아 죽는 구조였습니다.

per-row pan은 스크롤 끄기와 애초에 양립할 수 없었습니다. 손가락을 떼지 않았는데 제스처가 한 번 죽고, 다음 터치에서 새로 잡히니까 “두 번 눌러야 잡힌다”가 된 거였습니다.

두 번째 구조: 제스처를 하나로 합치다

해결은 제스처를 목록 바깥으로 빼는 거였습니다. 행마다 달려 있던 제스처를 전부 떼고, ScrollView 전체를 감싸는 단 하나의 Pan으로 합쳤습니다.

<GestureDetector gesture={rootPan}>
    <ScrollView ref={scrollRef}>
        {flatRows.map((it) => <FolderRow .../>)}
    </ScrollView>
</GestureDetector>

이러면 드래그 중에 자식 스크롤을 꺼도 제스처는 안 끊깁니다. 제스처가 스크롤뷰의 부모에 있으니까요. (react-native-draggable-flatlist가 쓰는 구조이기도 합니다.)

대신 행마다 갖고 있던 정보 하나를 잃습니다. 어느 행이 잡혔는가. 제스처가 목록 전체를 덮으니, 손가락이 정확히 어느 행 위에 있는지 직접 계산해야 했습니다.

제스처 흐름은 이렇게 정리됐습니다.

  • onBegin: 최초 터치 y좌표를 기록하고, 220ms 무장 타이머를 켠다.
  • onTouchesMove: 무장됐으면 제스처를 활성화. 무장 전에 12px 이상 움직였으면 드래그를 포기하고 스크롤에 양보한다.
  • onStart: 기록해 둔 최초 y좌표로 “잡힌 행”을 찾는다. 여기서 스크롤을 끈다.
  • onUpdate: 손가락을 따라 잡힌 행을 움직이고, 지금 어디에 떨어뜨릴지 계산한다.
  • onEnd: 떨어뜨린다.

220ms 홀드와 12px 양보가 핵심입니다. 가만히 짚고 있으면 드래그, 바로 그으면 스크롤. 사용자는 의식하지 않지만 손가락은 알아서 둘을 구분합니다.

어디에 떨어뜨릴 것인가

행마다 제스처가 있을 땐 OS가 hit-test를 해줬습니다. 이제는 직접 해야 합니다.

먼저 보이는 트리를 평탄하게 펼쳐서 한 줄씩 렌더링합니다. 그러면 각 행의 onLayout이 주는 y좌표가 목록 내용 기준 절대좌표가 됩니다. 이 좌표들을 맵에 담아둡니다.

드래그 중 손가락의 화면 좌표는 이렇게 목록 좌표로 바꿉니다.

const contentY = absoluteY - listTop + scrollY;

화면 절대 위치에서 목록의 상단 위치를 빼고, 현재 스크롤 오프셋을 더하면 “내용 안에서의 위치”가 나옵니다. 이 값으로 어느 행 위에 있는지 찾습니다.

그다음, 한 행 안에서도 위/가운데/아래를 나눕니다.

const rel = (contentY - row.y) / row.height;
const zone = rel < 0.28 ? "before" : rel > 0.72 ? "after" : "into";

행 높이의 위쪽 28%는 “이 행 앞에”, 아래쪽 28%는 “이 행 뒤에”, 가운데 44%는 “이 폴더 안에”. 가운데를 넓게 잡은 건, 폴더 안에 넣는 게 더 자주 쓰는 동작이라고 봤기 때문입니다. 그리고 자기 자신이나 자기 자손 위에는 못 떨어뜨리게 막습니다. 폴더가 자기 안에 들어가면 트리가 깨지니까요.

그리고 미세한 버그 셋

여기까지 오니 동작은 했습니다. 그런데 눈에 거슬리는 게 셋 남았습니다. 셋 다 “한 프레임”의 문제였습니다.

하나, 드롭하면 갔다가 돌아왔습니다. 폴더를 놓으면 잠깐 엉뚱한 자리로 슬라이드됐다가 제자리로 돌아오는 깜빡임. 원인은 순서였습니다. 손가락을 따라가던 translateY를 0으로 되돌리는 것과, 새 순서를 데이터에 반영하는 것이 다른 타이밍에 일어났습니다. 그 사이 한두 프레임 동안 “새 레이아웃인데 옛 translateY가 남은” 상태가 보였던 겁니다. 둘을 같은 동기 블록에 묶어 한 프레임에 처리하니 사라졌습니다.

둘, 폴더 위에 올리면 옆 행들이 밀렸습니다. 폴더 안에 넣을 수 있다는 표시로 테두리를 그렸는데, 테두리 두께가 0에서 1.5로 변하면서 행이 그만큼 커지고 주변이 출렁였습니다. 해결은 단순합니다. 테두리는 항상 1.5로 그려두고, 평소엔 투명, 표시할 때만 색을 입힙니다. 두께가 안 변하니 레이아웃도 안 움직입니다.

borderWidth: 1.5,  // 항상 1.5
borderColor: showInto ? PRIMARY500 : "transparent",  // 색만 토글

셋, 한 번 드롭하고 나면 일부 행만 드래그됐습니다. 가장 헷갈렸던 버그입니다. 폴더를 펼치거나 접을 때 좌표 맵을 통째로 비웠는데, 그 직후 화면이 다시 그려지면서 각 행의 onLayout이 새 좌표를 채워 넣습니다. 비우기와 채우기가 경쟁하면서, 살아 있는 행의 새 좌표까지 같이 지워졌습니다. 좌표가 없는 행은 hit-test에서 안 잡히고, 그래서 “일부만 드래그됨”이 됐습니다. 통째로 비우는 대신, 안 보이게 된 행만 골라서 지우도록 바꾸니 해결됐습니다.

덤: 한글이 안 써졌다

폴더 만들기 입력창에서 한글이 안 써졌습니다. 영어는 되는데 한글만.

드로어가 Portal 위에 떠 있는데, 그 Portal 안에 또 TextInput을 Portal로 감싸 띄우니 한글 IME가 입력을 못 받았습니다. 드로어 자체가 이미 떠 있는 레이어라 모달을 Portal로 또 감쌀 필요가 없었습니다. Portal 래핑을 빼니 한글이 정상으로 들어왔습니다.

배운 것

  1. 스크롤 안의 드래그는 제스처를 바깥에 둬라. 행마다 제스처를 달면 스크롤을 끄는 순간 자기가 죽는다. 목록 전체를 감싼 제스처 하나가 답이다. hit-test는 직접 하면 된다.

  2. “가만히 짚으면 드래그, 바로 그으면 스크롤.” 220ms 홀드와 약간의 이동 허용치만으로 두 세로 동작을 자연스럽게 가른다.

  3. 시각 상태는 한 프레임 안에서 원자적으로 바꿔라. 위치 되돌리기와 데이터 반영이 다른 타이밍이면 깜빡인다. 같은 블록에 묶어라.

  4. 변하면 안 되는 건 처음부터 그려둬라. 테두리는 두께를 바꾸지 말고 색만 바꿔라. 레이아웃을 건드리지 않는다.

  5. 캐시는 통째로 비우지 말고 골라서 지워라. 비우기와 다시 채우기가 경쟁하면, 살아 있는 것까지 잃는다.


전에도 같은 교훈을 적었는데, 형태만 바꿔서 또 나타났습니다. 스레드와 프레임 사이의 틈은 늘 거기에 있습니다.