Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/basic-example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ import NativeDetector from './NativeDetector';
import Navigator from './Navigator';
import RuntimeDecoration from './RuntimeDecoration';
import Text from './Text';
import VisualPress from './VisualPress';

const EXAMPLES = [
{
name: 'Visual Press',
component: VisualPress,
},
{
name: 'Text',
component: Text,
Expand Down
149 changes: 149 additions & 0 deletions apps/basic-example/src/VisualPress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React, { useRef, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import { ScrollView, Touchable } from 'react-native-gesture-handler';

type DisableOn = 'never' | 'longPress' | 'press';

export default function VisualPress() {
const [pressed, setPressed] = useState(false);
const [disabled, setDisabled] = useState(false);
const [cancelOnLeave, setCancelOnLeave] = useState(true);
const [disableOn, setDisableOn] = useState<DisableOn>('never');
const [events, setEvents] = useState<string[]>([]);
const [counts, setCounts] = useState({ active: 0, resting: 0, presses: 0 });
const startedAt = useRef(Date.now());

const record = (event: string) => {
const elapsed = Date.now() - startedAt.current;
setEvents((previous) => [...previous.slice(-11), `${elapsed}ms ${event}`]);
};

return (
<View style={styles.container}>
<Text style={styles.instructions}>
Drag the card to scroll, tap it, or hold then drag outside. Feedback
follows native acceptance. Enable re-entry by turning off cancel on
leave. The log keeps quick-tap transitions visible.
</Text>
<View style={styles.option}>
<Text>Disabled</Text>
<Switch
testID="visual-disabled"
value={disabled}
onValueChange={setDisabled}
/>
</View>
<View style={styles.option}>
<Text>Cancel on leave</Text>
<Switch value={cancelOnLeave} onValueChange={setCancelOnLeave} />
</View>
<View style={styles.controls}>
{(['never', 'longPress', 'press'] as const).map((mode) => (
<Touchable
key={mode}
testID={`disable-on-${mode}`}
style={[styles.control, disableOn === mode && styles.selected]}
onPress={() => setDisableOn(mode)}>
<Text>
{mode === 'never' ? 'Stay enabled' : `Disable on ${mode}`}
</Text>
</Touchable>
))}
</View>
<Text testID="visual-counts" style={styles.status}>
Active {counts.active}, resting {counts.resting}, presses{' '}
{counts.presses}
</Text>
<Text testID="visual-state" style={styles.status}>
Visual pressed {pressed ? 'yes' : 'no'}
</Text>
<Touchable
testID="visual-reset"
style={styles.control}
onPress={() => {
setEvents([]);
setCounts({ active: 0, resting: 0, presses: 0 });
startedAt.current = Date.now();
setDisabled(false);
}}>
<Text>Reset log and enable</Text>
</Touchable>
<ScrollView
testID="visual-scroll"
delaysChildPressedState
contentContainerStyle={styles.scrollContent}>
<Touchable
testID="visual-card"
disabled={disabled}
cancelOnLeave={cancelOnLeave}
activeOpacity={1}
activeScale={1}
activeUnderlayOpacity={0}
onVisualPressChange={(nextPressed: boolean) => {
setPressed(nextPressed);
setCounts((previous) => ({
...previous,
active: previous.active + (nextPressed ? 1 : 0),
resting: previous.resting + (nextPressed ? 0 : 1),
}));
record(`visual ${nextPressed}`);
}}
onPressIn={() => record('press in')}
onPressOut={() => record('press out')}
onLongPress={() => {
record('long press');
if (disableOn === 'longPress') {
setDisabled(true);
}
}}
onPress={() => {
record('press');
setCounts((previous) => ({
...previous,
presses: previous.presses + 1,
}));
if (disableOn === 'press') {
setDisabled(true);
}
}}>
<View
style={[
styles.card,
pressed && styles.pressed,
disabled && styles.disabled,
]}>
<Text style={styles.cardTitle}>Custom press feedback</Text>
<Text>The callback controls this card's color and scale.</Text>
</View>
</Touchable>
<Text testID="visual-log" style={styles.log}>
{events.length === 0 ? 'No events' : events.join('\n')}
</Text>
<View style={styles.spacer}>
<Text>Scroll space</Text>
</View>
</ScrollView>
</View>
);
}

const styles = StyleSheet.create({
container: { flex: 1, padding: 12, gap: 8, backgroundColor: '#fff' },
instructions: { color: '#333', fontSize: 13 },
option: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
controls: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
control: { padding: 8, borderRadius: 6, backgroundColor: '#eee' },
selected: { backgroundColor: '#cbb5ff' },
status: { fontSize: 14, fontWeight: '600' },
scrollContent: { padding: 8 },
card: { padding: 24, borderRadius: 16, backgroundColor: '#e4d9ff', gap: 8 },
cardTitle: { fontSize: 20, fontWeight: '600' },
pressed: { backgroundColor: '#b692ff', transform: [{ scale: 0.96 }] },
disabled: { opacity: 0.4 },
log: { marginTop: 16, fontSize: 12, minHeight: 180 },
spacer: { height: 800, paddingTop: 40 },
});
20 changes: 20 additions & 0 deletions packages/docs-gesture-handler/docs/components/touchable.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,26 @@ enum PointerType {

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

### onVisualPressChange

```tsx
onVisualPressChange?: (pressed: boolean) => void;
```

Called when Touchable selects its built-in pressed (`true`) or resting (`false`) feedback target. Native scroll views can defer `true` while deciding whether a touch starts a scroll. For quick taps, `false` follows the built-in press-in hold and can arrive after `onPressOut` and `onPress`.

Use this callback for custom feedback when `activeOpacity`, `activeScale`, and `activeUnderlayOpacity` are insufficient. Initialize custom state to `false`. The callback emits changes only, with no initial value or replay when the callback changes. A final callback after unmount is not guaranteed.

```tsx
const [pressed, setPressed] = useState(false);

<Touchable onVisualPressChange={setPressed} onPress={selectItem}>
<Text style={{ color: pressed ? 'purple' : 'black' }}>Select item</Text>
</Touchable>
```

This callback runs in JavaScript. It reports animation targets, not animation completion, and does not guarantee UI-thread execution or frame synchronization. Existing `onPressIn`, `onPressOut`, and `onPress` callbacks keep their interaction semantics. Hover feedback can remain active after `false`.

### onPressOut

<CollapsibleCode
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { ButtonEvent as SpecButtonEvent } from '../src/specs/RNGestureHandlerButtonNativeComponent';
import type { TouchableProps } from '../src/v3/components/Touchable/TouchableProps';
import type { ButtonEvent } from '../src/v3/types';

// Instantiate with a conditional type that resolves to `true` — a `false`
Expand All @@ -16,3 +17,15 @@ type ButtonEventMatchesSpec = StaticAssert<
: false
: false
>;

type TouchableHasVisualPressCallback = StaticAssert<
TouchableProps['onVisualPressChange'] extends
| ((pressed: boolean) => void)
| undefined
? true
: false
>;

type TouchableHidesVisualPressTransport = StaticAssert<
'onButtonVisualPressChange' extends keyof TouchableProps ? false : true
>;
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import com.swmansion.gesturehandler.core.HoverGestureHandler
import com.swmansion.gesturehandler.core.NativeViewGestureHandler
import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager.ButtonViewGroup
import com.swmansion.gesturehandler.react.events.RNGestureHandlerButtonEvent
import com.swmansion.gesturehandler.react.events.RNGestureHandlerButtonVisualPressEvent

@ReactModule(name = RNGestureHandlerButtonViewManager.REACT_CLASS)
class RNGestureHandlerButtonViewManager :
Expand Down Expand Up @@ -489,6 +490,7 @@ class RNGestureHandlerButtonViewManager :

private fun dropManagedHandler(view: ButtonViewGroup) {
val tag = view.managedHandlerTag ?: return
view.resetVisualPressState()
// Cleared even if the drop below doesn't go through — the view must not keep a dangling tag.
view.managedHandlerTag = null

Expand Down Expand Up @@ -606,6 +608,7 @@ class RNGestureHandlerButtonViewManager :
private var receivedKeyEvent = false
private var currentAnimator: AnimatorSet? = null
private var underlayDrawable: PaintDrawable? = null
private var visualPressed = false
private var pressInTimestamp = 0L
private var pendingPressOut: Runnable? = null
private var pendingLongPress: Runnable? = null
Expand Down Expand Up @@ -1042,12 +1045,35 @@ class RNGestureHandlerButtonViewManager :
}
}

private fun setVisualPressed(pressed: Boolean) {
if (visualPressed == pressed) {
return
}
visualPressed = pressed
if (managedHandlerTag == null) {
return
}
val reactContext = context as ReactContext
// TODO: deprecated, but its replacement is unavailable before RN 0.85 — drop when possible
val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return
eventDispatcher.dispatchEvent(RNGestureHandlerButtonVisualPressEvent(this, pressed))
}

fun resetVisualPressState() {
pendingPressOut?.let { handler?.removeCallbacks(it) }
pendingPressOut = null
pressInTimestamp = 0L
setVisualPressed(false)
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, 0)
Comment on lines +1063 to +1067
}

private fun animatePressIn() {
pendingPressOut?.let {
handler?.removeCallbacks(it)
pendingPressOut = null
}
pressInTimestamp = SystemClock.uptimeMillis()
setVisualPressed(true)
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, tapAnimationInDuration.toLong())
}

Expand All @@ -1056,6 +1082,7 @@ class RNGestureHandlerButtonViewManager :
return
}

setVisualPressed(false)
if (effectiveHover) {
animateTo(hoverOpacity, hoverScale, hoverUnderlayOpacity, hoverAnimationInDuration.toLong())
} else {
Expand Down Expand Up @@ -1194,6 +1221,7 @@ class RNGestureHandlerButtonViewManager :

private fun animatePressOut() {
pendingPressOut?.let { handler?.removeCallbacks(it) }
pendingPressOut = null
val tapInMs = tapAnimationInDuration.toLong()
val tapOutMs = tapAnimationOutDuration.toLong()
val longPressMs = longPressDuration.toLong()
Expand All @@ -1202,19 +1230,23 @@ class RNGestureHandlerButtonViewManager :

if (longPressMs >= 0 && elapsed >= longPressMs) {
// Long-press release - use the configured long-press out duration.
setVisualPressed(false)
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, longPressOutMs)
} else if (elapsed >= tapInMs) {
// Press-in animation fully finished — release with the configured out duration.
setVisualPressed(false)
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, tapOutMs)
// elapsed * 2 to ensure there is at least half of the tapAnimationOutDuration left for the animation to play
} else if (elapsed * 2 >= tapOutMs) {
setVisualPressed(false)
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, elapsed)
} else {
val remaining = tapInMs - elapsed
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)

val runnable = Runnable {
pendingPressOut = null
setVisualPressed(false)
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, tapOutMs)
}
pendingPressOut = runnable
Expand Down Expand Up @@ -1356,6 +1388,8 @@ class RNGestureHandlerButtonViewManager :
// and re-inserting, so detaching is not proof the pointer left. A genuine
// teardown clears it in `onDropViewInstance` instead.
isHovered = false
pressInTimestamp = 0L
setVisualPressed(false)
applyStartAnimationState()

if (touchResponder === this) {
Expand Down Expand Up @@ -1494,14 +1528,12 @@ class RNGestureHandlerButtonViewManager :
return
}

if (!enabled && isPressed) {
setPressed(false)
if (!enabled && (isPressed || visualPressed)) {
if (isPressed) {
setPressed(false)
}

// No finger lifted, so skip the press-out fade: it would overlap the
// restyle that usually comes with disabling and show as a flash.
pendingPressOut?.let { handler?.removeCallbacks(it) }
pendingPressOut = null
animateTo(restingOpacity, restingScale, restingUnderlayOpacity, 0)
resetVisualPressState()
}

// The managed handler mirrors the button's enabled state.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.swmansion.gesturehandler.react.events

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager

class RNGestureHandlerButtonVisualPressEvent(
button: RNGestureHandlerButtonViewManager.ButtonViewGroup,
private val pressed: Boolean,
) : Event<RNGestureHandlerButtonVisualPressEvent>(UIManagerHelper.getSurfaceId(button), button.id) {
override fun getEventName() = "onButtonVisualPressChange"

override fun canCoalesce() = false

override fun getEventData(): WritableMap = Arguments.createMap().apply {
putBoolean("pressed", pressed)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ typedef NS_ENUM(NSInteger, RNGHButtonEventType) {
- (void)dispatchButtonEvent:(RNGHButtonEventType)type
withExtraData:(nullable RNGestureHandlerEventExtraData *)extraData;

- (void)dispatchVisualPressChange:(BOOL)pressed;

@end

#if TARGET_OS_OSX
Expand Down Expand Up @@ -100,6 +102,7 @@ typedef NS_ENUM(NSInteger, RNGHButtonEventType) {
* and the underlay opacity to neutral values.
*/
- (void)prepareForRecycle;
- (void)resetVisualPressState;

/**
* Updates the underlay layer's corner radii with separate horizontal/vertical
Expand Down
Loading