🐝매일 한입
개발일지📖 11분 읽기

Utakoto — 테두리를 지워도 각져 보이던 시트 모서리, 그리고 바텀 시트 16곳에 끌어 닫기

어제 위쪽 테두리를 지운 미리보기 시트가 실기기에서 여전히 각져 보였던 원인은 시트 윗변에서 끝나던 막이었습니다. 막을 화면 전체로 넓히고, 아래로 끌어 닫는 공통 훅을 바텀 시트 16곳에 붙여 같은 날 OTA 로 냈습니다.

📖 11분 읽기
#Utakoto#React Native#PanResponder#바텀 시트#OTA#1인개발

어제 고친 줄 알았던 모서리

어제 글에서 미리보기 시트의 위쪽 테두리를 지웠다고 썼습니다. 위쪽에만 선을 주면 선이 둥근 모서리를 따라가지 못해 각져 보인다는 이유였고, 곡 요청 시트도 같은 방식으로 고쳐서(21f7099) OTA 로 냈습니다.

그런데 catalog-browse-views 의 plan 에 오늘 날짜로 붙은 후속 기록을 보면, PO 가 실기기에서 확인했을 때 미리보기 시트 모서리는 여전히 각져 보였습니다. 하나가 더 있었습니다. 시트를 아래로 끌어도 닫히지 않는다는 것입니다. 어제 발행 기록에는 "발행 전 실기기 탭 동작·Android 확인은 못 했다"고 적혀 있었는데, 그 빈칸에서 두 가지가 나온 셈입니다.

선이 아니라 막이었다

원인은 테두리가 아니라 막(scrim, 시트 뒤를 어둡게 덮는 반투명 면)이었습니다. 미리보기 시트와 곡 요청 시트는 막을 flex: 1 로 시트 위쪽 빈 곳에만 깔고, 그 아래에 시트를 붙이는 구조였습니다. 그러면 막은 시트 윗변에서 끝납니다. 시트의 위 두 모서리는 둥글게 깎여 있으니, 깎여 나간 작은 귀퉁이에는 막이 없고 어둡게 덮이지 않은 원래 화면이 그대로 비칩니다. 밝은 화면 조각이 모서리를 다시 채워 각져 보였던 것입니다.

그래서 막을 화면 전체에 깔고, 시트는 그 위에 따로 얹었습니다.

      {/* 막은 화면 전체를 덮는다 — 시트 위에서 끝나면 둥근 모서리 뒤로 막 없는 화면이 비쳐 각져 보인다. */}
      <Pressable
        onPress={onClose}
        accessibilityLabel="닫기"
        style={[StyleSheet.absoluteFill, { backgroundColor: colors.base + '8C' }]}
      />
      <KeyboardAvoidingView
        behavior={Platform.OS === 'ios' ? 'padding' : undefined}
        pointerEvents="box-none"
        style={{ flex: 1, justifyContent: 'flex-end' }}
      >

곡 요청 시트의 diff 입니다. 시트를 담는 바깥 상자에 pointerEvents="box-none" 을 준 것은, 그 상자가 화면 전체를 차지하면서도 빈 곳의 탭은 아래의 막으로 흘려보내 막을 누르면 닫히는 동작을 지키기 위해서입니다. 다른 시트들(테마, 한자, 단어장 필터 등)은 처음부터 막이 시트를 품는 부모였기 때문에 이 문제가 없었고, diff 에서도 막 쪽은 손대지 않았습니다.

끌어 닫기를 한 곳에 모으기

끌어 닫기는 시트마다 따로 만들지 않고 useSheetDrag 라는 훅 하나로 모아 바텀 시트 16곳에 붙였습니다. 커밋 메시지대로 110pt 넘게 끌어내리거나 빠르게 튕기면 닫히고, 그보다 덜 끌면 제자리로 돌아갑니다.

/** 이만큼 끌어내리거나(pt) 이 속도로 튕기면 닫는다. */
const CLOSE_DISTANCE = 110;
const CLOSE_VELOCITY = 0.9;
/** 스크롤되는 시트는 맨 위에서 이만큼(pt) 더 당겼다 놓으면 닫는다. */
const CLOSE_OVERSCROLL = 70;

어려운 부분은 시트 안에 스크롤이 있을 때였습니다. 훅의 주석에 적힌 대로 iOS 스크롤 뷰는 손짓을 가로채기 때문에, 시트를 끌려는 손가락이 스크롤에 먹힙니다. 그래서 두 경우로 나눴습니다. 내용이 시트 안에 다 들어와 스크롤할 필요가 없으면 스크롤을 아예 꺼서 손짓이 시트로 가게 합니다. 내용이 길어 스크롤이 필요하면, 머리나 손잡이에서 시작한 손짓은 언제나 시트를 끌고, 스크롤 안에서 시작한 손짓은 내용이 맨 위에 있을 때만 시트를 끕니다. 맨 위에서 70pt 넘게 더 당겼다 놓아도 닫힙니다.

      // 스크롤 안에서 시작한 손짓은 내용이 맨 위일 때만, 머리·손잡이에서 시작한 손짓은 언제나 시트를 끈다.
      onMoveShouldSetPanResponderCapture: (_e, g) =>
        (!live.current.inScroll || live.current.scrollTop <= 0) && g.dy > 8 && g.dy > Math.abs(g.dx) * 1.5,

아래로 8pt 넘게, 그리고 가로 이동의 1.5배보다 크게 움직였을 때만 끌기로 봅니다. 가로로 넘기는 칩 줄 같은 것과 부딪히지 않게 하려는 조건으로 읽힙니다. 스크롤이 필요한지는 내용 높이와 스크롤 틀 높이를 재서 정하고(size.content > size.frame + 1), 미리보기·곡 요청 시트에 있던 bounces={false} 도 diff 에서 빠졌습니다. 이유가 따로 적혀 있지는 않지만, 맨 위에서 더 당겨 닫는 동작은 내용이 맨 위를 지나 더 끌려 내려갈 수 있어야 성립하니 함께 가는 변경으로 보입니다.

단어장의 JLPT 필터 시트도 16곳 중 하나입니다. 이 시트는 내용이 짧아 스크롤이 없으니, 시트 어디를 잡고 내려도 끌립니다.

Utakoto 단어장 화면, 총 단어 수와 N5부터 N1까지의 레벨 필터, 노래 속 단어 목록과 뜻이 보인다 Utakoto 단어장 화면(스토어 스크린샷)입니다. 여기서 여는 JLPT 필터 시트에도 이번 끌어 닫기가 붙었습니다.

코드 주석에 남은 판단들

이번 변경에는 따로 intent·spec 이 없고, 고려한 길은 커밋 메시지와 코드 주석에 남아 있습니다. 그중 몇 가지는 해 보고 안 돼서 바꾼 기록입니다.

  • 끌기 손짓과 이동은 NativeWind className 이 없는 Animated.View 감싸개가 받습니다. 주석에는 className 을 단 Animated.View 에서는 손짓이 먹지 않았다고 적혀 있습니다.
  • onStartShouldSetPanResponder 는 늘 true 입니다. 시트의 빈 곳이나 글자에서 시작한 손짓을 받을 쪽이 없으면 끌기가 잡히지 않았다고 합니다. 안쪽 버튼은 더 깊은 곳에 있어 먼저 받습니다.
  • 닫기를 막는 시트(처리 중일 때 등)에서는 onClose 를 불러도 시트가 남습니다. 끌린 자리에 그대로 멈춰 있지 않게, 닫기를 부른 뒤 450ms 뒤에 위치를 0으로 되돌립니다.
  • 동작 줄이기 설정에서는 시트가 손가락을 따라 내려오지 않고, 놓을 때 닫히기만 합니다.

반대로, 바텀 시트 라이브러리를 들이는 대신 PanResponder 로 직접 만든 이유는 기록에 남아 있지 않습니다. 제가 diff 에서 본 시트들은 모두 React Native Modal 위에 이미 만들어져 있었지만, 그것이 이유였다고는 쓰여 있지 않습니다.

확인한 것과 못 본 것

plan 의 후속 기록에 따르면 iOS 시뮬레이터와 Android 에뮬레이터에서 Maestro 로 탭과 끌기를 확인했습니다. 미리보기·신고·필터·테마·필기 소리 시트는 두 플랫폼 모두, 단어장 필터·카드 공유·한자 시트는 iOS 에서만입니다.

못 본 것도 같은 줄에 적혀 있습니다. 곡 요청, 포인트 부족, 로그인 안내, 알림 허용, 곡 끝, 곡 관리, 모드 선택 시트, 그리고 키보드가 올라온 상태입니다. 곡 요청 시트와 카탈로그 필터 시트는 검색 입력이 있어 KeyboardAvoidingView 안에 있는데, 키보드가 떠 있을 때 끌기가 어떻게 동작하는지는 아직 확인 기록이 없습니다.

발행은 같은 날 했습니다(2a76936). main 의 1444412 로 OTA 7926ef8c(runtime 1.11.0, iOS·Android)를 냈습니다.

Utakoto 는 J-POP·애니송 가사로 일본어를 배우는 앱입니다. 가사마다 후리가나·한국어 발음·뜻이 붙고, 노래 속 단어가 JLPT 단어장으로 모여 복습할 수 있습니다. iOS·Android 에서 무료입니다.

📚 관련 글

💬 댓글