<TouchableOpacity onPress={...} /> 만 써도 대부분의 버튼은 동작한다. 하지만 햅틱 피드백을 정확히 누르는 순간에 주고 싶거나, 길게 누르기와 짧게 누르기를 구분하거나, 드래그 중 손가락이 버튼 밖으로 살짝 빠져나가도 클릭을 유지하고 싶다면 onPress 하나로는 부족하다.
React Native는 터치의 라이프사이클을 여러 단계로 쪼개서 노출하는데, 각 이벤트가 언제 발화하는지, 어떤 순서로 일어나는지를 이해하면 인터랙션 설계가 훨씬 정교해진다. 이 글에서는 Pressable(및 Touchable*)이 제공하는 press 관련 이벤트와 그 동작 모델을 정리해보자.
본 글은
PressableAPI를 기준으로 한다.TouchableOpacity/TouchableHighlight/TouchableWithoutFeedback도 내부적으로 동일한 Pressability API를 사용하므로 동일한 이벤트들이 노출된다. 신규 프로젝트라면 React Native 팀이 권장하는 대로Pressable을 우선 사용하는 것을 권한다.
손가락이 버튼에 닿는 순간부터 떼는 순간까지, press의 라이프사이클은 다음 단계로 나뉜다.
[손가락이 닿음]
│
▼
onPressIn ──────── 항상 가장 먼저 호출됨
│
├── (500ms 이내에 손을 뗌)
│ │
│ ▼
│ onPressOut ──── 손가락이 떨어짐
│ │
│ ▼
│ onPress ──── 짧은 탭 완료
│
└── (500ms 이상 누르고 있음)
│
▼
onLongPress ─── 길게 누름이 감지됨
│
▼ (이후 손을 뗌)
onPressOut ─── onPress 는 호출되지 않음
onPressIn은 항상 가장 먼저 호출된다.onPressOut → onPress 순서다. onPress가 먼저가 아니다.onLongPress가 발화하면 onPress는 호출되지 않는다. 단, onPressOut은 손을 떼는 시점에 여전히 호출된다.이 순서는 직관과 다를 때가 많아서, 햅틱 피드백이나 애니메이션을 어느 이벤트에 걸지 결정할 때 반드시 의식해야 한다.
onPressIn — 가장 빠른 응답 지점손가락이 닿는 순간 즉시 호출된다. 사용자가 "이 버튼을 누르려고 한다"는 의도를 시스템이 처음으로 인지하는 시점이다. 다음의 순간 때 사용한다.
Haptics.impactAsync()나 react-native-haptic-feedback은 onPress가 아니라 onPressIn에 거는 게 자연스럽다. 손가락이 닿는 순간 떨림이 와야 사용자가 "눌렸다"고 느낀다.<Pressable
onPressIn={() => HapticFeedback.trigger('impactLight')}
onPress={() => navigation.navigate('Detail')}
>
<Text>상세 보기</Text>
</Pressable>
onPressIn에 비즈니스 로직(API 호출, 네비게이션 등)을 걸면 안 된다. 사용자가 실수로 닿기만 해도 발화하기 때문이다. 그건onPress의 역할이다.
onPressOut — 손가락이 떨어지는 순간손가락이 화면에서 떨어지거나, 누름 영역을 벗어나서 press가 취소되는 순간 호출된다. onPressIn과 짝을 이뤄 누름 상태의 시작/끝을 표시하며 다음의 순간 때 사용한다.
onPressIn에서 시작한 시각 피드백 되돌리기. scale, 색상, 그림자 등을 원상복구한다.onPressIn에서 시간을 기록하고 onPressOut에서 경과 시간을 계산하면 사용자가 얼마나 오래 눌렀는지 알 수 있다.const scale = useSharedValue(1);
<Pressable
onPressIn={() => { scale.value = withSpring(0.95); }}
onPressOut={() => { scale.value = withSpring(1); }}
onPress={handleSubmit}
>
...
</Pressable>
Pressable은 style={({ pressed }) => ...} 형태로 같은 효과를 더 간결하게 표현할 수도 있지만, Reanimated 같은 외부 애니메이션 시스템과 통합하려면 onPressIn / onPressOut을 직접 쓰는 편이 자연스럽다.
onPress — 탭이 "완성된" 시점onPressIn 후 사용자가 손을 떼고, 그 행동이 "짧은 탭"으로 판정되면 호출된다. onPressOut 다음에 호출된다는 점이 중요하다.
대부분의 비즈니스 로직(네비게이션, 폼 제출, 토글)은 여기에 둔다. 사용자가 누름을 완료했다는 의도가 가장 명확한 시점이기 때문이다.
주의: onLongPress가 발화한 경우 onPress는 호출되지 않는다. 같은 버튼에 두 이벤트를 함께 걸어두면, 사용자가 길게 누른 경우 자동으로 짧은 탭 로직은 건너뛴다.
onLongPress — 길게 누르기손가락이 일정 시간(기본 500ms) 이상 누름 상태를 유지하면 호출된다. 이 시간은 delayLongPress prop으로 조정할 수 있다.
<Pressable
onPress={() => console.log('짧은 탭')}
onLongPress={() => console.log('길게 누름')}
delayLongPress={800}
>
...
</Pressable>
사용처
중요한 동작
onLongPress가 호출된 뒤에는 onPress가 호출되지 않는다. 시스템이 "사용자가 다른 의도(긴 누름)였다"고 판단하기 때문이다.onPressOut은 손을 떼는 시점에 여전히 호출된다. 시각 피드백 정리는 onLongPress 분기와 무관하게 일관되게 동작한다.onPressMove (덜 알려진 이벤트)Pressable은 누른 상태에서 손가락이 움직일 때 호출되는 onPressMove도 노출한다(Pressable 한정). 일반적인 버튼에는 거의 안 쓰지만, 누른 채로 슬라이드하는 인터랙션(볼륨 슬라이더, 커스텀 노브)을 만들 때 유용하다. 다만 진짜 제스처 기반 UI라면 이걸 쓰기보다 react-native-gesture-handler를 도입하는 편이 안정적이다.
사용자가 작은 버튼을 누를 때 손가락은 종종 버튼 가장자리를 살짝 벗어난다. React Native는 이 현실을 보정하기 위해 두 개의 가상 영역을 제공한다.
hitSlop — 누름이 시작될 수 있는 영역 확장<Pressable hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
...
</Pressable>
hitSlop은 버튼 바깥쪽으로 터치를 인식하는 영역을 확장한다. 시각적으로는 작은 버튼이지만 누름 영역(HitRect)은 더 넓게 잡히는 효과다. 작은 아이콘 버튼, 닫기(X) 버튼처럼 시각 크기가 작은 컴포넌트에 거의 필수적으로 적용해야 한다 — 접근성 가이드라인(iOS는 44pt, Android는 48dp)이 권장하는 최소 터치 영역을 시각 디자인을 깨지 않고 확보하는 표준 수단이다.
pressRetentionOffset — 누른 상태가 유지되는 영역hitSlop이 "press가 시작될 수 있는 영역"이라면, pressRetentionOffset은 "press가 시작된 후 손가락이 어디까지 움직여도 press가 유지되는가"를 정의한다.
<Pressable
hitSlop={10}
pressRetentionOffset={{ top: 30, bottom: 30, left: 30, right: 30 }}
onPress={handleSubmit}
>
...
</Pressable>
사용자가 버튼을 누른 채로 손가락을 살짝 미끄러뜨려도, pressRetentionOffset 범위 안이라면 여전히 누름이 유효하다. 손가락을 그 범위 밖으로 빼면 onPressOut이 호출되고 press는 취소된다(이 경우 onPress도 호출되지 않는다).
이 두 영역의 관계를 그림으로 표현하면:
┌─────────────────────────────────┐ ← PressRect (pressRetentionOffset)
│ │
│ ┌─────────────────────┐ │ ← HitRect (hitSlop)
│ │ │ │
│ │ ┌─────────────┐ │ │
│ │ │ 버튼 │ │ │ ← 실제 시각적 영역
│ │ └─────────────┘ │ │
│ │ │ │
│ └─────────────────────┘ │
│ │
└─────────────────────────────────┘
onPressOut이 호출된다.press의 발화 타이밍을 미세 조정할 수 있는 prop들이 있다. 평소엔 기본값으로 충분하지만 알고 있으면 디버깅이 쉬워진다.
| Prop | 역할 | 기본값 |
|---|---|---|
delayPressIn | 손가락이 닿은 뒤 onPressIn을 호출하기까지 대기 시간(ms) | 0 |
delayPressOut | 손가락이 떨어진 뒤 onPressOut을 호출하기까지 대기 시간(ms) | 0 (플랫폼/구현에 따라 약간의 내부 지연이 있을 수 있음) |
delayLongPress | onPressIn 이후 onLongPress 호출까지 대기 시간(ms) | 500 |
delayPressIn을 작은 값으로 두면 시각 피드백이 너무 민감해 보이는 경우(스크롤 중 살짝만 닿아도 버튼이 눌린 듯이 표시되는)에 완화 효과가 있다. 다만 일반 버튼에는 거의 손대지 않는 게 좋다.
disabled와 이벤트의 관계disabled={true}인 Pressable은 어떤 press 이벤트도 호출하지 않는다. onPressIn도 발화하지 않으므로, "비활성 버튼인데 누르면 미세하게 흔들리는 피드백"을 구현하려면 disabled 대신 내부 상태로 제어하고 onPress에서 분기하는 방식이 낫다.
// ❌ 비활성 상태에서 햅틱조차 안 나감
<Pressable disabled={!canSubmit} onPressIn={vibrate} onPress={submit} />
// ✅ 누름 피드백은 살리고, 실행만 막음
<Pressable
onPressIn={vibrate}
onPress={canSubmit ? submit : showWhyDisabled}
style={({ pressed }) => [
styles.button,
!canSubmit && styles.disabled,
pressed && styles.pressed,
]}
/>
햅틱을 onPress에 걸면 늦다
onPress는 사용자가 손을 뗀 후에 호출된다. 햅틱 피드백을 여기 걸면 "누르고 → 떼고 → 진동"이 되어 직관과 어긋난다. 햅틱은 onPressIn에.
onPress와 onLongPress를 둘 다 거는 경우
onLongPress가 발화하면 onPress는 호출되지 않는다는 점을 잊지 말 것. "긴 누름이면 메뉴, 짧은 누름이면 네비게이션"을 의도했는데 둘 다 발화한다면, 그건 Touchable*의 옛 동작이 아니라 다른 곳(상위 컴포넌트의 이중 핸들러, 제스처 충돌)에서 트리거되고 있을 가능성이 높다.
스크롤 중 의도치 않은 press 발화
ScrollView 내부의 버튼은 스크롤 제스처와 press가 충돌할 수 있다. 대부분 React Native가 잘 처리하지만, onPressIn에서 시작한 시각 피드백이 스크롤 중에 깜빡거리는 경우가 있다. 이런 경우 delayPressIn을 100~150ms 정도로 늘리면 된다.
pressRetentionOffset을 너무 크게 잡으면 의도치 않은 클릭
여러 버튼이 가까이 있을 때 pressRetentionOffset을 크게 잡으면, 한 버튼을 누른 채로 다른 버튼 위로 손가락을 옮겨도 원래 버튼의 press가 유지된다. 이 경우 사용자가 "옆 버튼을 눌렀다"고 생각하지만 실제로는 첫 버튼이 호출된다. 인접 버튼이 있을 때는 보수적으로 크기를 잡자.
TouchableOpacity 사용 시 동일한 이벤트가 작동하는가
작동한다. Touchable* 컴포넌트들도 내부적으로 동일한 Pressability API를 사용하므로 onPress, onPressIn, onPressOut, onLongPress, hitSlop, pressRetentionOffset, delayLongPress 모두 동일하게 쓸 수 있다. 단, style={({ pressed }) => ...} 형태의 함수형 스타일은 Pressable 전용이다.
| 목적 | 추천 이벤트 |
|---|---|
| 햅틱 피드백 | onPressIn |
| 누름 상태 시각 피드백 시작 | onPressIn |
| 누름 상태 시각 피드백 종료 | onPressOut |
| 비즈니스 로직 (네비, 제출, 토글) | onPress |
| 컨텍스트 메뉴 / 멀티선택 진입 | onLongPress |
| 사운드 재생 시작 | onPressIn |
| 사운드 정지 | onPressOut |
| 작은 버튼의 터치 영역 확장 | hitSlop |
| 누른 채 드래그 허용 범위 | pressRetentionOffset |
press 이벤트는 단순히 "눌렸나 안 눌렸나"의 이진값이 아니라, 시작 → 유지 → 분기(짧은 탭 vs 긴 누름) → 종료의 라이프사이클이다. 각 단계에 맞는 핸들러를 적절히 분배하면, 같은 버튼이라도 훨씬 반응이 살아 있는 컴포넌트가 된다.