Skip to content

Commit 4727219

Browse files
authored
[Android] Dispatch onPressIn when the native press starts (#4575)
## Description On Android `onPressIn` was sent when the `Native` gesture began, i.e. on the `DOWN`. The platform defers the pressed state inside scrolling containers and drops it when a parent intercepts the touch, so a tap that stops a fling fired `onPressIn` and `onPressOut` with no `onPress`, and feedback driven from `onPressIn` ran ahead of the native ripple (#4515). `onPressIn` and the long press timer now follow `setPressed(true)`, the moment the native press feedback starts. The long press timer is still measured from the `DOWN`. `onPressOut`, `onPress` and the `pressOutAfterAnimation` hold are unchanged. Fixes #4515 ## Test plan <details> <summary>Tested on the following code:</summary> ```tsx import React, { useRef, useState } from 'react'; import { Animated, FlatList as RNFlatList, StyleSheet, Text, View, } from 'react-native'; import { FlatList, Touchable } from 'react-native-gesture-handler'; // onPressIn follows the native press (#4515, #4547). Every row scales down from onPressIn and // back from onPressOut, next to the native ripple, so the two should start together. // - Fling the list, then stop it with a finger on a row and lift: no callback should fire. // - Start a fast swipe on a row: no onPressIn. Press, hold, then drag: onPressIn with the // ripple, onPressOut when the scroll takes over. // - Hold a row: onLongPress at ~500 ms, in both lists. const ROWS = Array.from({ length: 200 }, (_, i) => i); const LONG_PRESS_MS = 500; const COLORS = ['#6750a4', '#0b6e4f', '#b3261e', '#1d4ed8', '#b45309']; type Counts = { pressIn: number; pressOut: number; press: number; longPress: number; }; const EMPTY_COUNTS: Counts = { pressIn: 0, pressOut: 0, press: 0, longPress: 0, }; function Row({ index, count, }: { index: number; count: (key: keyof Counts) => void; }) { const scale = useRef(new Animated.Value(1)).current; const animateTo = (value: number) => Animated.timing(scale, { toValue: value, duration: 100, useNativeDriver: true, }).start(); const color = COLORS[index % COLORS.length]; return ( <Touchable androidRipple={{ color: 'rgba(0, 0, 0, 0.12)' }} delayLongPress={LONG_PRESS_MS} onPressIn={() => { count('pressIn'); animateTo(0.95); }} onPressOut={() => { count('pressOut'); animateTo(1); }} onPress={() => count('press')} onLongPress={() => count('longPress')}> <Animated.View style={[styles.row, { transform: [{ scale }] }]}> <View style={[styles.avatar, { backgroundColor: color }]}> <Text style={styles.avatarText}>{index}</Text> </View> <View> <Text style={styles.rowTitle}>Row {index}</Text> <Text style={styles.rowSubtitle}> scales on onPressIn, ripple on native press </Text> </View> </Animated.View> </Touchable> ); } function Stat({ label, value }: { label: string; value: number }) { return ( <View style={styles.stat}> <Text style={styles.statValue}>{value}</Text> <Text style={styles.statLabel}>{label}</Text> </View> ); } export default function PressInNativeTimingExample() { const [useRNGHList, setUseRNGHList] = useState(true); const [counts, setCounts] = useState<Counts>(EMPTY_COUNTS); const count = (key: keyof Counts) => setCounts((c) => ({ ...c, [key]: c[key] + 1 })); const List = useRNGHList ? FlatList : RNFlatList; return ( <View style={styles.root}> <View style={styles.panel}> <View style={styles.stats}> <Stat label="pressIn" value={counts.pressIn} /> <Stat label="pressOut" value={counts.pressOut} /> <Stat label="press" value={counts.press} /> <Stat label="longPress" value={counts.longPress} /> </View> <View style={styles.actions}> <Touchable style={styles.button} onPress={() => setUseRNGHList((v) => !v)}> <Text style={styles.buttonText}> {useRNGHList ? 'RNGH FlatList' : 'RN FlatList'} </Text> </Touchable> <Touchable style={[styles.button, styles.buttonSecondary]} onPress={() => setCounts(EMPTY_COUNTS)}> <Text style={[styles.buttonText, styles.buttonSecondaryText]}> Reset </Text> </Touchable> </View> <Text style={styles.hint}> Fling, then stop the list with a finger on a row: nothing should count. Hold a row for onLongPress. </Text> </View> <List key={useRNGHList ? 'rngh' : 'rn'} data={ROWS} keyExtractor={(i) => String(i)} contentContainerStyle={styles.content} renderItem={({ item }) => <Row index={item} count={count} />} /> </View> ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#f4f3f8' }, panel: { margin: 16, marginBottom: 8, padding: 16, borderRadius: 16, backgroundColor: '#fff', gap: 12, shadowColor: '#000', shadowOpacity: 0.08, shadowRadius: 12, shadowOffset: { width: 0, height: 4 }, elevation: 3, }, stats: { flexDirection: 'row', justifyContent: 'space-between' }, stat: { alignItems: 'flex-start' }, statValue: { fontSize: 24, fontWeight: '700', color: '#1c1b1f' }, statLabel: { fontSize: 12, color: '#79747e' }, actions: { flexDirection: 'row', gap: 8 }, button: { flex: 1, alignItems: 'center', paddingVertical: 10, borderRadius: 10, backgroundColor: '#6750a4', }, buttonText: { color: '#fff', fontWeight: '600' }, buttonSecondary: { flex: 0, paddingHorizontal: 16, backgroundColor: '#e8e8ee', }, buttonSecondaryText: { color: '#1c1b1f' }, hint: { color: '#5f5c66', fontSize: 13, lineHeight: 18 }, content: { paddingHorizontal: 16, paddingBottom: 16, gap: 8 }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 12, borderRadius: 14, backgroundColor: '#fff', }, avatar: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', }, avatarText: { color: '#fff', fontWeight: '700' }, rowTitle: { fontSize: 16, fontWeight: '600', color: '#1c1b1f' }, rowSubtitle: { fontSize: 12, color: '#79747e', marginTop: 2 }, }); ``` </details>
1 parent 13f4c7b commit 4727219

1 file changed

Lines changed: 49 additions & 9 deletions

File tree

‎packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,10 @@ class RNGestureHandlerButtonViewManager :
738738
// container) instead of pressing right away.
739739
private var pressDeferredByPlatform = false
740740

741+
// Uptime of the DOWN that began the current press sequence; the long press timer is measured from it.
742+
private var pressDownTime = 0L
743+
private var pressStarted = false
744+
741745
override fun onHandlerUpdate(handler: NativeViewGestureHandler) {
742746
if (managedHandlerTag == null || handler.isWithinBounds == lastEventWasInside) {
743747
return
@@ -773,17 +777,16 @@ class RNGestureHandlerButtonViewManager :
773777
// native dispatch of the same DOWN and the sweep that reads the flag.
774778
receivedNativeDown = false
775779
flushPendingPressOut()
776-
dispatchJSEvent(EventType.PressIn, handler)
780+
pressDownTime = SystemClock.uptimeMillis()
781+
pressStarted = false
777782
longPressDetected = false
778783

779-
if (hasLongPressHandler && longPressDuration >= 0) {
780-
val runnable = Runnable {
781-
pendingLongPress = null
782-
longPressDetected = true
783-
dispatchJSEvent(EventType.LongPress, handler)
784-
}
785-
pendingLongPress = runnable
786-
this.handler?.postDelayed(runnable, longPressDuration.toLong())
784+
// Touch-driven presses report PressIn and arm the long press timer from `setPressed`, when the
785+
// press feedback starts. A synthetic activation (TalkBack, keyboard) has no touch and no
786+
// pressed state to follow, so it reports PressIn right away. Nothing deferred it either.
787+
if (!handler.hasTrackedPointers) {
788+
pressDeferredByPlatform = false
789+
dispatchJSEvent(EventType.PressIn, handler)
787790
}
788791
}
789792

@@ -819,6 +822,42 @@ class RNGestureHandlerButtonViewManager :
819822
}
820823
}
821824

825+
// The framework defers the pressed state inside scrolling containers and drops it when a parent
826+
// intercepts the DOWN (e.g. a fling catch), so PressIn and the long press timer follow the pressed
827+
// state, not BEGAN. Like `View.CheckForTap`, long press is still measured from the DOWN.
828+
private fun onPressedStateStarted() {
829+
val moduleId = moduleId ?: return
830+
val handlerTag = managedHandlerTag ?: return
831+
val handler =
832+
RNGestureHandlerModule.registries[moduleId]?.getHandler(handlerTag) as? NativeViewGestureHandler ?: return
833+
834+
if (handler.state != GestureHandler.STATE_BEGAN && handler.state != GestureHandler.STATE_ACTIVE) {
835+
return
836+
}
837+
838+
if (!lastEventWasInside) {
839+
dispatchJSEvent(EventType.PressIn, handler)
840+
}
841+
842+
// Re-entry after leaving (cancelOnLeave off) reports PressIn again but never re-arms the timer.
843+
if (pressStarted) {
844+
return
845+
}
846+
847+
pressStarted = true
848+
849+
if (hasLongPressHandler && longPressDuration >= 0) {
850+
val elapsed = SystemClock.uptimeMillis() - pressDownTime
851+
val runnable = Runnable {
852+
pendingLongPress = null
853+
longPressDetected = true
854+
dispatchJSEvent(EventType.LongPress, handler)
855+
}
856+
pendingLongPress = runnable
857+
this.handler?.postDelayed(runnable, (longPressDuration - elapsed).coerceAtLeast(0))
858+
}
859+
}
860+
822861
private fun dispatchJSEvent(type: EventType, handler: NativeViewGestureHandler) {
823862
dispatchEvent(RNGestureHandlerButtonEvent.obtain(this, handler, type))
824863

@@ -1595,6 +1634,7 @@ class RNGestureHandlerButtonViewManager :
15951634

15961635
if (pressed) {
15971636
animatePressIn()
1637+
onPressedStateStarted()
15981638
} else {
15991639
animatePressOut()
16001640
}

0 commit comments

Comments
 (0)