Skip to content

Commit 43b28f4

Browse files
authored
[Native] Add cancelOnLeave prop to Touchable (#4105)
## Description Adds `cancelOnLeave` prop to the `Touchable` component, set to `true` by default. It doesn't take effect on the web for now. It prevents `NativeViewGestureHandler` from canceling when the pointer leaves the view bounds, and tracks `pointerInside` from update events to call `onPressIn` and `onPressOut` callbacks. `onPress` is called only if the pointer is released in the bounds of the button. ## Test plan <details> <summary>Expand</summary> ```jsx import React, { useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Touchable } from 'react-native-gesture-handler'; export default function EmptyExample() { const [isActive1, setIsActive1] = useState(false); const [isActive2, setIsActive2] = useState(false); return ( <View style={styles.container}> <Touchable style={[styles.button, isActive1 && styles.buttonActive]} onPress={() => console.log('pressed')} onPressIn={() => { console.log('onPressIn'); setIsActive1(true) }} onPressOut={() => { console.log('onPressOut'); setIsActive1(false) }} activeScale={1.05} exclusive={false} cancelOnLeave={true}> <Text style={styles.label}>{isActive1 ? 'PRESS ME' : 'Press me'}</Text> </Touchable> <Touchable style={[styles.button, isActive2 && styles.buttonActive]} onPress={() => console.log('pressed')} onPressIn={() => { console.log('onPressIn'); setIsActive2(true) }} onPressOut={() => { console.log('onPressOut'); setIsActive2(false) }} activeScale={1.05} exclusive={false} cancelOnLeave={true}> <Text style={styles.label}>{isActive2 ? 'PRESS ME' : 'Press me'}</Text> </Touchable> <Text style={styles.hint}> Press the button, then slowly drag your pointer away from it. </Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, gap: 24, }, button: { backgroundColor: '#ddd', paddingHorizontal: 32, paddingVertical: 16, borderRadius: 8, }, buttonActive: { backgroundColor: '#f97316', }, label: { fontSize: 20, fontWeight: '600', }, hint: { textAlign: 'center', opacity: 0.6, fontSize: 14, }, }); ``` </details> |-|cancelOnLeave=true|cancelOnLeave=false| |-|-|-| |Android|<video src="https://github.com/user-attachments/assets/b2fb1d24-4dd1-4324-99ec-81c11a419af9" />|<video src="https://github.com/user-attachments/assets/7080ef75-6a86-44c9-ad7a-2f49326c03ad" />| |iOS|<video src="https://github.com/user-attachments/assets/bcaa4f1d-8ce2-4265-a9ff-76a86479814f" />|<video src="https://github.com/user-attachments/assets/e01b6a31-799b-45cb-ba5a-46854c29abf0" />|
1 parent bfa4f35 commit 43b28f4

5 files changed

Lines changed: 138 additions & 16 deletions

File tree

packages/docs-gesture-handler/docs/components/touchable.mdx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,9 +270,28 @@ Triggered when the button gets released or the pointer moves outside of the butt
270270

271271
### onPress
272272

273-
```ts
274-
onPress?: (pointerInside: boolean) => void;
275-
```
273+
<CollapsibleCode
274+
label="Show composed types definitions"
275+
expandedLabel="Hide composed types definitions"
276+
lineBounds={[0, 1]}
277+
src={`
278+
onPress?: (e: GestureEvent<NativeHandlerData>) => void;
279+
280+
type GestureEvent<NativeHandlerData> = {
281+
handlerTag: number;
282+
numberOfPointers: number;
283+
pointerType: PointerType;
284+
pointerInside: boolean;
285+
}
286+
287+
enum PointerType {
288+
TOUCH,
289+
STYLUS,
290+
MOUSE,
291+
KEY,
292+
OTHER,
293+
}
294+
`}/>
276295

277296
Triggered when the button gets pressed (analogous to `onPress` in `Pressable` from RN core).
278297

@@ -314,3 +333,13 @@ type PressableAndroidRippleConfig = {
314333
`}/>
315334

316335
Configuration for the ripple effect on Android. If not provided, the ripple effect will be disabled. If `{}` is provided, the ripple effect will be enabled with default configuration.
336+
337+
<Badges platforms={['android', 'ios']}>
338+
### cancelOnLeave
339+
</Badges>
340+
341+
```ts
342+
cancelOnLeave?: boolean;
343+
```
344+
345+
Whether the touch should be canceled when the pointer leaves the component. By default set to `true`. On web this prop doesn't have any effect and behaves as if `true` was set.

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

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ class RNGestureHandlerButtonViewManager :
379379
private var underlayDrawable: PaintDrawable? = null
380380
private var pressInTimestamp = 0L
381381
private var pendingPressOut: Runnable? = null
382+
private var isPointerInsideBounds = false
382383

383384
// When non-null the ripple is drawn in dispatchDraw (above background, below children).
384385
// When null the ripple lives on the foreground drawable instead.
@@ -481,7 +482,30 @@ class RNGestureHandlerButtonViewManager :
481482
if (lastEventTime != eventTime || lastAction != action || action == MotionEvent.ACTION_CANCEL) {
482483
lastEventTime = eventTime
483484
lastAction = action
484-
return super.onTouchEvent(event)
485+
val handled = super.onTouchEvent(event)
486+
487+
// Replay press-in / press-out animations across drag transitions.
488+
if (handled && canRespondToTouches()) {
489+
when (event.actionMasked) {
490+
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> isPointerInsideBounds = true
491+
MotionEvent.ACTION_MOVE -> {
492+
val inside = event.x >= 0 && event.y >= 0 && event.x < width && event.y < height
493+
if (inside != isPointerInsideBounds) {
494+
isPointerInsideBounds = inside
495+
if (inside) {
496+
animatePressIn()
497+
} else {
498+
animatePressOut()
499+
}
500+
}
501+
}
502+
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_CANCEL ->
503+
isPointerInsideBounds =
504+
false
505+
}
506+
}
507+
508+
return handled
485509
}
486510
return false
487511
}
@@ -719,6 +743,12 @@ class RNGestureHandlerButtonViewManager :
719743
}
720744
}
721745

746+
private fun canRespondToTouches(): Boolean = if (exclusive) {
747+
touchResponder === this
748+
} else {
749+
!(touchResponder?.exclusive ?: false)
750+
}
751+
722752
private fun tryGrabbingResponder(): Boolean {
723753
if (isChildTouched()) {
724754
return false
@@ -728,11 +758,8 @@ class RNGestureHandlerButtonViewManager :
728758
touchResponder = this
729759
return true
730760
}
731-
return if (exclusive) {
732-
touchResponder === this
733-
} else {
734-
!(touchResponder?.exclusive ?: false)
735-
}
761+
762+
return canRespondToTouches()
736763
}
737764

738765
private fun isChildTouched(children: Sequence<View> = this.children): Boolean {

packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ - (void)commonInit
8080

8181
#if !TARGET_OS_TV && !TARGET_OS_OSX
8282
[self setExclusiveTouch:YES];
83-
[self addTarget:self action:@selector(handleAnimatePressIn) forControlEvents:UIControlEventTouchDown];
83+
[self addTarget:self
84+
action:@selector(handleAnimatePressIn)
85+
forControlEvents:UIControlEventTouchDown | UIControlEventTouchDragEnter];
8486
[self addTarget:self
8587
action:@selector(handleAnimatePressOut)
8688
forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchDragExit |
@@ -566,22 +568,29 @@ - (void)setUnderlayBorderInsetsWithTop:(CGFloat)top right:(CGFloat)right bottom:
566568
#if TARGET_OS_OSX
567569
- (void)mouseDown:(NSEvent *)event
568570
{
571+
_isTouchInsideBounds = YES;
569572
[self handleAnimatePressIn];
570573
[super mouseDown:event];
571574
}
572575

573576
- (void)mouseUp:(NSEvent *)event
574577
{
575578
[self handleAnimatePressOut];
579+
_isTouchInsideBounds = NO;
576580
[super mouseUp:event];
577581
}
578582

579583
- (void)mouseDragged:(NSEvent *)event
580584
{
581585
NSPoint locationInWindow = [event locationInWindow];
582586
NSPoint locationInView = [self convertPoint:locationInWindow fromView:nil];
587+
BOOL currentlyInside = NSPointInRect(locationInView, self.bounds);
583588

584-
if (!NSPointInRect(locationInView, self.bounds)) {
589+
if (currentlyInside && !_isTouchInsideBounds) {
590+
_isTouchInsideBounds = YES;
591+
[self handleAnimatePressIn];
592+
} else if (!currentlyInside && _isTouchInsideBounds) {
593+
_isTouchInsideBounds = NO;
585594
[self handleAnimatePressOut];
586595
}
587596
}

packages/react-native-gesture-handler/src/v3/components/Touchable/Touchable.tsx

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ const TouchableButton = createNativeWrapper<
2222
const isAndroid = Platform.OS === 'android';
2323
const TRANSPARENT_RIPPLE = { rippleColor: 'transparent' as const };
2424

25+
enum PointerState {
26+
UNKNOWN,
27+
INSIDE,
28+
OUTSIDE,
29+
}
30+
2531
export const Touchable = (props: TouchableProps) => {
2632
const {
2733
underlayColor = 'black',
@@ -35,12 +41,14 @@ export const Touchable = (props: TouchableProps) => {
3541
onPressOut,
3642
children,
3743
disabled = false,
44+
cancelOnLeave = true,
3845
ref,
3946
...rest
4047
} = props;
4148

4249
const shouldUseNativeRipple = isAndroid && androidRipple !== undefined;
4350

51+
const pointerState = useRef<PointerState>(PointerState.UNKNOWN);
4452
const longPressDetected = useRef(false);
4553
const longPressTimeout = useRef<ReturnType<typeof setTimeout> | undefined>(
4654
undefined
@@ -62,11 +70,14 @@ export const Touchable = (props: TouchableProps) => {
6270
const onBegin = useCallback(
6371
(e: CallbackEventType) => {
6472
if (!e.pointerInside) {
73+
pointerState.current = PointerState.OUTSIDE;
6574
return;
6675
}
6776

6877
onPressIn?.(e);
6978
startLongPressTimer();
79+
80+
pointerState.current = PointerState.INSIDE;
7081
},
7182
[startLongPressTimer, onPressIn]
7283
);
@@ -80,16 +91,20 @@ export const Touchable = (props: TouchableProps) => {
8091

8192
const onDeactivate = useCallback(
8293
(e: EndCallbackEventType) => {
83-
if (!e.canceled && !longPressDetected.current) {
84-
onPress?.(e.pointerInside);
94+
if (!e.canceled && !longPressDetected.current && e.pointerInside) {
95+
onPress?.(e);
8596
}
8697
},
8798
[onPress]
8899
);
89100

90101
const onFinalize = useCallback(
91102
(e: EndCallbackEventType) => {
92-
onPressOut?.(e);
103+
if (pointerState.current === PointerState.INSIDE) {
104+
onPressOut?.(e);
105+
}
106+
107+
pointerState.current = PointerState.UNKNOWN;
93108

94109
if (longPressTimeout.current !== undefined) {
95110
clearTimeout(longPressTimeout.current);
@@ -99,6 +114,32 @@ export const Touchable = (props: TouchableProps) => {
99114
[onPressOut]
100115
);
101116

117+
const onUpdate = useCallback(
118+
(e: CallbackEventType) => {
119+
if (pointerState.current === PointerState.UNKNOWN) {
120+
return;
121+
}
122+
123+
if (e.pointerInside) {
124+
if (pointerState.current === PointerState.OUTSIDE) {
125+
onPressIn?.(e);
126+
}
127+
pointerState.current = PointerState.INSIDE;
128+
} else {
129+
if (pointerState.current === PointerState.INSIDE) {
130+
onPressOut?.(e);
131+
132+
if (longPressTimeout.current !== undefined) {
133+
clearTimeout(longPressTimeout.current);
134+
longPressTimeout.current = undefined;
135+
}
136+
}
137+
pointerState.current = PointerState.OUTSIDE;
138+
}
139+
},
140+
[onPressIn, onPressOut]
141+
);
142+
102143
const rippleProps = shouldUseNativeRipple
103144
? {
104145
rippleColor: androidRipple?.color,
@@ -118,9 +159,11 @@ export const Touchable = (props: TouchableProps) => {
118159
onActivate={onActivate}
119160
onDeactivate={onDeactivate}
120161
onFinalize={onFinalize}
162+
onUpdate={onUpdate}
121163
defaultOpacity={defaultOpacity}
122164
defaultUnderlayOpacity={defaultUnderlayOpacity}
123-
underlayColor={underlayColor}>
165+
underlayColor={underlayColor}
166+
shouldCancelWhenOutside={cancelOnLeave}>
124167
{children}
125168
</TouchableButton>
126169
);

packages/react-native-gesture-handler/src/v3/components/Touchable/TouchableProps.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,20 @@ type PressableAndroidRippleConfig = {
1818
type RippleProps = 'rippleColor' | 'rippleRadius' | 'borderless' | 'foreground';
1919

2020
export type TouchableProps = Omit<ButtonProps, RippleProps | 'enabled'> &
21-
Omit<BaseButtonProps, keyof RawButtonProps | 'onActiveStateChange'> & {
21+
Omit<
22+
BaseButtonProps,
23+
keyof RawButtonProps | 'onActiveStateChange' | 'onPress'
24+
> & {
2225
/**
2326
* Configuration for the ripple effect on Android.
2427
*/
2528
androidRipple?: PressableAndroidRippleConfig | undefined;
2629

30+
/**
31+
* Called when the component gets pressed.
32+
*/
33+
onPress?: ((event: CallbackEventType) => void) | undefined;
34+
2735
/**
2836
* Called when pointer touches the component.
2937
*/
@@ -38,4 +46,10 @@ export type TouchableProps = Omit<ButtonProps, RippleProps | 'enabled'> &
3846
* Whether the component should ignore touches. By default set to false.
3947
*/
4048
disabled?: boolean | undefined;
49+
50+
/**
51+
* Whether the touch should be canceled when pointer leaves the component. By default set to true.
52+
* On web this prop doesn't have any effect and behaves as if `true` was set.
53+
*/
54+
cancelOnLeave?: boolean | undefined;
4155
};

0 commit comments

Comments
 (0)