Skip to content

Commit 491fa9f

Browse files
authored
Merge pull request #323 from Smartdevs17/feat-gesture-fixed
Feat: improve subscription gesture handling
2 parents 2affffa + e4523ab commit 491fa9f

4 files changed

Lines changed: 338 additions & 8 deletions

File tree

App.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import { View } from 'react-native';
33
import { StatusBar } from 'expo-status-bar';
4+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
45
import { AppNavigator } from './src/navigation/AppNavigator';
56
import { useNotifications } from './src/hooks/useNotifications';
67
import { useTransactionQueue } from './src/hooks/useTransactionQueue';
@@ -82,13 +83,15 @@ function NotificationBootstrap() {
8283

8384
export default function App() {
8485
return (
85-
<View style={{ flex: 1 }} testID="app-root">
86-
<StatusBar style="light" />
87-
<ErrorBoundary>
88-
<NotificationBootstrap />
89-
<AppNavigator />
90-
</ErrorBoundary>
91-
<AppKit />
92-
</View>
86+
<GestureHandlerRootView style={{ flex: 1 }}>
87+
<View style={{ flex: 1 }} testID="app-root">
88+
<StatusBar style="light" />
89+
<ErrorBoundary>
90+
<NotificationBootstrap />
91+
<AppNavigator />
92+
</ErrorBoundary>
93+
<AppKit />
94+
</View>
95+
</GestureHandlerRootView>
9396
);
9497
}
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import React, { useMemo, useRef, useState } from 'react';
2+
import {
3+
Animated,
4+
PanResponder,
5+
PanResponderGestureState,
6+
Pressable,
7+
StyleSheet,
8+
Text,
9+
View,
10+
} from 'react-native';
11+
12+
import { borderRadius, colors, shadows, spacing, typography } from '../../utils/constants';
13+
import {
14+
buildGestureDebugLabel,
15+
GestureDirection,
16+
resolveGesturePriority,
17+
triggerGestureFeedback,
18+
validateHorizontalSwipe,
19+
} from '../../services/gestureService';
20+
21+
interface SwipeableCardProps {
22+
children: React.ReactNode;
23+
onPress: () => void;
24+
onLongPress?: () => void;
25+
onSwipeLeft?: () => void;
26+
onSwipeRight?: () => void;
27+
accessibilityLabel?: string;
28+
debugEnabled?: boolean;
29+
}
30+
31+
const MAX_SWIPE_TRANSLATION = 96;
32+
33+
function clampTranslate(value: number): number {
34+
return Math.max(Math.min(value, MAX_SWIPE_TRANSLATION), -MAX_SWIPE_TRANSLATION);
35+
}
36+
37+
export const SwipeableCard: React.FC<SwipeableCardProps> = ({
38+
children,
39+
onPress,
40+
onLongPress,
41+
onSwipeLeft,
42+
onSwipeRight,
43+
accessibilityLabel,
44+
debugEnabled = false,
45+
}) => {
46+
const translateX = useRef(new Animated.Value(0)).current;
47+
const draggingRef = useRef(false);
48+
const longPressTriggeredRef = useRef(false);
49+
const [debugLabel, setDebugLabel] = useState('gesture=tap direction=none');
50+
51+
const resetPosition = () => {
52+
Animated.spring(translateX, {
53+
toValue: 0,
54+
useNativeDriver: true,
55+
bounciness: 8,
56+
speed: 16,
57+
}).start();
58+
};
59+
60+
const completeSwipe = (direction: GestureDirection, action?: () => void) => {
61+
Animated.sequence([
62+
Animated.timing(translateX, {
63+
toValue: direction === 'right' ? 72 : -72,
64+
duration: 120,
65+
useNativeDriver: true,
66+
}),
67+
Animated.spring(translateX, {
68+
toValue: 0,
69+
useNativeDriver: true,
70+
bounciness: 6,
71+
speed: 18,
72+
}),
73+
]).start();
74+
75+
if (action) {
76+
triggerGestureFeedback('success');
77+
action();
78+
}
79+
};
80+
81+
const handleRelease = (gestureState: PanResponderGestureState) => {
82+
const result = validateHorizontalSwipe(gestureState);
83+
const priority = resolveGesturePriority(result, longPressTriggeredRef.current);
84+
85+
if (debugEnabled) {
86+
setDebugLabel(buildGestureDebugLabel({ ...result, priority }, gestureState));
87+
}
88+
89+
draggingRef.current = false;
90+
longPressTriggeredRef.current = false;
91+
92+
if (priority !== 'swipe') {
93+
resetPosition();
94+
return;
95+
}
96+
97+
if (result.direction === 'right') {
98+
completeSwipe('right', onSwipeRight);
99+
return;
100+
}
101+
102+
if (result.direction === 'left') {
103+
completeSwipe('left', onSwipeLeft);
104+
return;
105+
}
106+
107+
resetPosition();
108+
};
109+
110+
const panResponder = useMemo(
111+
() =>
112+
PanResponder.create({
113+
onMoveShouldSetPanResponder: (_, gestureState) =>
114+
Math.abs(gestureState.dx) > 8 && Math.abs(gestureState.dx) > Math.abs(gestureState.dy),
115+
onPanResponderGrant: () => {
116+
draggingRef.current = false;
117+
},
118+
onPanResponderMove: (_, gestureState) => {
119+
draggingRef.current = true;
120+
translateX.setValue(clampTranslate(gestureState.dx));
121+
if (debugEnabled) {
122+
const result = validateHorizontalSwipe(gestureState);
123+
setDebugLabel(buildGestureDebugLabel(result, gestureState));
124+
}
125+
},
126+
onPanResponderTerminationRequest: () => true,
127+
onPanResponderRelease: (_, gestureState) => handleRelease(gestureState),
128+
onPanResponderTerminate: (_, gestureState) => handleRelease(gestureState),
129+
}),
130+
[debugEnabled, onSwipeLeft, onSwipeRight, translateX]
131+
);
132+
133+
return (
134+
<View style={styles.wrapper}>
135+
<View pointerEvents="none" style={styles.actionBackground}>
136+
<Text style={styles.leftActionText}>Quick toggle</Text>
137+
<Text style={styles.rightActionText}>Open</Text>
138+
</View>
139+
<Animated.View
140+
{...panResponder.panHandlers}
141+
style={[styles.animatedCard, { transform: [{ translateX }] }]}>
142+
<Pressable
143+
accessibilityRole="button"
144+
accessibilityLabel={accessibilityLabel}
145+
delayLongPress={320}
146+
onLongPress={() => {
147+
if (draggingRef.current) return;
148+
longPressTriggeredRef.current = true;
149+
triggerGestureFeedback('long-press');
150+
onLongPress?.();
151+
setTimeout(() => {
152+
longPressTriggeredRef.current = false;
153+
}, 700);
154+
}}
155+
onPress={() => {
156+
if (draggingRef.current) {
157+
resetPosition();
158+
return;
159+
}
160+
if (longPressTriggeredRef.current) {
161+
longPressTriggeredRef.current = false;
162+
return;
163+
}
164+
triggerGestureFeedback('tap');
165+
onPress();
166+
}}
167+
style={styles.pressable}>
168+
{children}
169+
</Pressable>
170+
</Animated.View>
171+
{debugEnabled ? (
172+
<View style={styles.debugBadge}>
173+
<Text style={styles.debugText}>{debugLabel}</Text>
174+
</View>
175+
) : null}
176+
</View>
177+
);
178+
};
179+
180+
const styles = StyleSheet.create({
181+
wrapper: {
182+
marginBottom: spacing.md,
183+
},
184+
actionBackground: {
185+
...StyleSheet.absoluteFillObject,
186+
flexDirection: 'row',
187+
justifyContent: 'space-between',
188+
alignItems: 'center',
189+
paddingHorizontal: spacing.lg,
190+
borderRadius: borderRadius.lg,
191+
backgroundColor: 'rgba(99, 102, 241, 0.12)',
192+
},
193+
leftActionText: {
194+
...typography.caption,
195+
color: colors.accent,
196+
fontWeight: '700',
197+
},
198+
rightActionText: {
199+
...typography.caption,
200+
color: colors.success,
201+
fontWeight: '700',
202+
},
203+
animatedCard: {
204+
borderRadius: borderRadius.lg,
205+
...shadows.sm,
206+
},
207+
pressable: {
208+
borderRadius: borderRadius.lg,
209+
},
210+
debugBadge: {
211+
marginTop: spacing.xs,
212+
backgroundColor: colors.surface,
213+
borderRadius: borderRadius.md,
214+
paddingHorizontal: spacing.sm,
215+
paddingVertical: spacing.xs,
216+
},
217+
debugText: {
218+
...typography.small,
219+
color: colors.textSecondary,
220+
},
221+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {
2+
buildGestureDebugLabel,
3+
resolveGesturePriority,
4+
validateHorizontalSwipe,
5+
} from '../gestureService';
6+
7+
describe('gestureService', () => {
8+
it('accepts a clear horizontal swipe', () => {
9+
const result = validateHorizontalSwipe({ dx: 88, dy: 12, vx: 0.4, vy: 0.02 });
10+
11+
expect(result.isValid).toBe(true);
12+
expect(result.direction).toBe('right');
13+
expect(result.priority).toBe('swipe');
14+
});
15+
16+
it('rejects vertical-dominant movement', () => {
17+
const result = validateHorizontalSwipe({ dx: 74, dy: 64, vx: 0.27, vy: 0.35 });
18+
19+
expect(result.isValid).toBe(false);
20+
expect(result.reason).toBe('vertical-dominant');
21+
});
22+
23+
it('resolves long press priority when no swipe is accepted', () => {
24+
const swipeResult = validateHorizontalSwipe({ dx: 10, dy: 2, vx: 0.01, vy: 0 });
25+
26+
expect(resolveGesturePriority(swipeResult, true)).toBe('long-press');
27+
expect(resolveGesturePriority(swipeResult, false)).toBe('tap');
28+
});
29+
30+
it('builds a readable debug label', () => {
31+
const result = validateHorizontalSwipe({ dx: -90, dy: 8, vx: -0.31, vy: 0.01 });
32+
const label = buildGestureDebugLabel(result, { dx: -90, dy: 8, vx: -0.31, vy: 0.01 });
33+
34+
expect(label).toContain('direction=left');
35+
expect(label).toContain('reason=accepted');
36+
});
37+
});

src/services/gestureService.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Platform, Vibration } from 'react-native';
2+
3+
export type GestureDirection = 'left' | 'right' | 'none';
4+
export type GesturePriority = 'swipe' | 'long-press' | 'tap';
5+
6+
export interface GestureSample {
7+
dx: number;
8+
dy: number;
9+
vx: number;
10+
vy: number;
11+
}
12+
13+
export interface GestureValidationResult {
14+
isValid: boolean;
15+
direction: GestureDirection;
16+
priority: GesturePriority;
17+
reason: string;
18+
}
19+
20+
const SWIPE_DISTANCE_THRESHOLD = 56;
21+
const SWIPE_VELOCITY_THRESHOLD = 0.22;
22+
const HORIZONTAL_DOMINANCE_RATIO = 1.35;
23+
24+
export function validateHorizontalSwipe(sample: GestureSample): GestureValidationResult {
25+
const absDx = Math.abs(sample.dx);
26+
const absDy = Math.abs(sample.dy);
27+
const direction: GestureDirection =
28+
sample.dx > 0 ? 'right' : sample.dx < 0 ? 'left' : 'none';
29+
30+
if (!direction || direction === 'none') {
31+
return { isValid: false, direction: 'none', priority: 'tap', reason: 'no-horizontal-motion' };
32+
}
33+
34+
if (absDx < SWIPE_DISTANCE_THRESHOLD && Math.abs(sample.vx) < SWIPE_VELOCITY_THRESHOLD) {
35+
return { isValid: false, direction, priority: 'tap', reason: 'below-threshold' };
36+
}
37+
38+
if (absDy > absDx / HORIZONTAL_DOMINANCE_RATIO) {
39+
return { isValid: false, direction, priority: 'tap', reason: 'vertical-dominant' };
40+
}
41+
42+
return { isValid: true, direction, priority: 'swipe', reason: 'accepted' };
43+
}
44+
45+
export function resolveGesturePriority(
46+
swipeResult: GestureValidationResult,
47+
longPressTriggered: boolean
48+
): GesturePriority {
49+
if (swipeResult.isValid) {
50+
return 'swipe';
51+
}
52+
53+
return longPressTriggered ? 'long-press' : 'tap';
54+
}
55+
56+
export function buildGestureDebugLabel(
57+
result: GestureValidationResult,
58+
sample: GestureSample
59+
): string {
60+
return `gesture=${result.priority} direction=${result.direction} dx=${sample.dx.toFixed(
61+
1
62+
)} dy=${sample.dy.toFixed(1)} vx=${sample.vx.toFixed(2)} reason=${result.reason}`;
63+
}
64+
65+
export function triggerGestureFeedback(kind: GesturePriority | 'success'): void {
66+
const duration = kind === 'success' ? 20 : Platform.OS === 'ios' ? 10 : 15;
67+
Vibration.vibrate(duration);
68+
}
69+

0 commit comments

Comments
 (0)