Жести та дотики
Жести та дотики
Відкриття: чому торкання — це не просто click
У вебі все просто: є onClick і цього зазвичай достатньо. У мобільному світі все інакше. Пальцями роблять:
- Тап — швидкий дотик і відпускання.
- Довгий тап — утримання понад ~500 мс.
- Свайп — ковзання по екрану з певним вектором.
- Пінч — зведення/розведення двох пальців для масштабування.
- Поворот — обертальний рух двома пальцями.
- Пан — плавне переміщення пальцем.
- Подвійний тап — два швидких дотики.
Операційна система обробляє кожен дотик у кілька фаз, дозволяє жестам «змагатись» між собою і надає розробнику точний контроль над тим, хто «перемагає». Розуміння цієї архітектури — ключ до чуйного, природного UI.
useState/useRef та основами Expo Router. Базові знання React Native Reanimated будуть корисні, але не обов'язкові — ми будемо використовувати Animated API зі стандартної бібліотеки.Архітектура обробки дотиків
Touch Event Pipeline
Перш ніж писати код, важливо розуміти, як touch-подія проходить шлях від пальця до обробника у вашому компоненті:
Ключовий момент: React Native Gesture Handler (RNGH) працює через Turbo Modules / JSI — синхронно на нативному потоці UI. Це означає, що жести реагують миттєво, без затримки JS потоку. Стандартний PanResponder — застаріший підхід, де події йдуть через асинхронний міст.
Responder System: як React Native вирішує, хто обробляє дотик
Коли палець торкається екрана і на шляху є кілька компонентів (наприклад, кнопка всередині скролу), виникає питання: хто з них «забирає» дотик? Це вирішує Responder System:
- Capture phase — подія іде вниз по дереву компонентів (від кореня до листя).
- Bubbling phase — кожен компонент вирішує, чи хоче він стати «responder».
- Negotiation — якщо два компоненти претендують — батько може заблокувати дитину через
onStartShouldSetResponderCapture.
Саме через цю систему виникає класична проблема: свайп-елемент всередині ScrollView «не реагує» — ScrollView перехоплює дотик раніше. RNGH вирішує це елегантніше через власний менеджер жестів.
Pressable: сучасна основа тапів
Чому Pressable, а не TouchableOpacity
До React Native 0.63 для кнопок використовували TouchableOpacity, TouchableHighlight або TouchableNativeFeedback. У 0.63 з'явився Pressable — гнучкіша і більш потужна заміна:
TouchableOpacity (застарілий)
- Лише opacity-анімація
- Немає доступу до стану
pressedу children - Немає
hitSlopз усіх боків окремо - Неможливо кастомізувати feedback
Pressable (сучасний)
- Callback-стиль:
style={({ pressed }) => ...} - Доступ до
pressedу children черезrender prop - Розширена
hitSlopконфігурація - Власний
android_rippleефект - Хуки
onHoverIn/onHoverOutдля web
Базове використання
import { Pressable, Text, StyleSheet } from 'react-native';
function MyButton({ onPress, label }: { onPress: () => void; label: string }) {
return (
<Pressable
onPress={onPress}
style={({ pressed }) => [
styles.button,
pressed && styles.buttonPressed,
]}
>
{({ pressed }) => (
<Text style={[styles.label, pressed && styles.labelPressed]}>
{label}
</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#3B82F6',
paddingHorizontal: 24,
paddingVertical: 14,
borderRadius: 12,
},
buttonPressed: {
backgroundColor: '#1D4ED8',
transform: [{ scale: 0.97 }],
},
label: { color: 'white', fontWeight: '700', fontSize: 16 },
labelPressed: { opacity: 0.85 },
});
Живий приклад Pressable
Loading…
react-native-web · not a real device
import { Pressable, Text, StyleSheet, View } from 'react-native';
import { useState } from 'react';
export default function App() {
const [count, setCount] = useState(0);
return (
<View style={styles.container}>
<Text style={styles.counter}>{count}</Text>
<Pressable
onPress={() => setCount((c) => c + 1)}
style={({ pressed }) => [styles.btn, pressed && styles.btnPressed]}
>
{({ pressed }) => (
<Text style={styles.label}>{pressed ? 'Тримаю!' : 'Натисни мене'}</Text>
)}
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 24 },
counter: { fontSize: 64, fontWeight: '800', color: '#3B82F6' },
btn: {
backgroundColor: '#3B82F6',
paddingHorizontal: 32,
paddingVertical: 16,
borderRadius: 16,
},
btnPressed: { backgroundColor: '#1D4ED8', transform: [{ scale: 0.95 }] },
label: { color: 'white', fontWeight: '700', fontSize: 18 },
});
Всі props Pressable
hitSlop.delayLongPress мілісекунд (дефолт: 500 мс).onLongPress. Дефолт: 500.{ top, bottom, left, right }.{ pressed: boolean } і повертає стиль. Дозволяє динамічно змінювати вигляд при натисканні.{ color, borderless, radius }. Ігнорується на iOS.false.React Native Gesture Handler (RNGH)
Навіщо потрібна ця бібліотека
Стандартна Responder System React Native має критичний недолік: вся обробка жестів відбувається у JavaScript потоці. Кожен onPanResponderMove — це асинхронне повідомлення через міст. При інтенсивних анімаціях (drag, swipe, pinch) це призводить до видимих запинок навіть на потужних пристроях.
React Native Gesture Handler переносить обробку жестів повністю на нативний UI потік. Це означає:
- Жести реагують навіть коли JS потік зайнятий (наприклад, виконує складні обчислення).
- Відсутність затримки між пальцем і реакцією.
- Можливість взаємодії з Reanimated shared values без bridge.
Встановлення та налаштування
npx expo install react-native-gesture-handler
Критично важливий крок: додати GestureHandlerRootView у корінь застосунку:
// app/_layout.tsx
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
{/* решта вашого layout */}
</GestureHandlerRootView>
);
}
GestureHandlerRootView у корені — жести RNGH не будуть працювати взагалі. Це найпоширеніша причина «чому нічого не відбувається після встановлення».Архітектура RNGH v2: Gesture API
RNGH v2 (починаючи з версії 2.0) представив новий декларативний API — Gesture + GestureDetector. Він замінив старі компоненти PanGestureHandler, TapGestureHandler тощо.
Принцип простий: ви описуєте жест, передаєте в <GestureDetector>, і він сам підключає нативні обробники:
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import { View } from 'react-native';
const tapGesture = Gesture.Tap()
.onEnd(() => {
console.log('Тап!');
});
function MyComponent() {
return (
<GestureDetector gesture={tapGesture}>
<View style={{ width: 100, height: 100, backgroundColor: 'blue' }} />
</GestureDetector>
);
}
Типи жестів у RNGH
.numberOfTaps(2) для подвійного тапу, .maxDuration(300) для тайм-ауту.translationX, translationY, velocityX, velocityY. Основний жест для drag-and-drop і swipe.scale (1.0 = вихідний розмір, 2.0 = вдвічі більше).rotation в радіанах..minDuration(800) (мс). Надає координати дотику..direction(Directions.RIGHT).Lifecycle колбеки жесту
Кожен жест має однаковий набір колбеків:
scale: 0.98 feedback.onChange, але передає повний стан жесту (не тільки зміну).success: boolean — чи жест завершився нормально або скасувався.onEnd або onFail / onCancel. Підходить для cleanup.Простий приклад: Tap жест
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import { View, Text, StyleSheet } from 'react-native';
import { useState } from 'react';
export default function TapExample() {
const [taps, setTaps] = useState(0);
const singleTap = Gesture.Tap()
.runOnJS(true) // колбек виконується в JS потоці
.onEnd(() => setTaps((n) => n + 1));
const doubleTap = Gesture.Tap()
.numberOfTaps(2)
.runOnJS(true)
.onEnd(() => setTaps(0)); // подвійний тап — скидає лічильник
// Комбінація: спочатку перевіряємо подвійний тап
const combined = Gesture.Exclusive(doubleTap, singleTap);
return (
<GestureDetector gesture={combined}>
<View style={styles.box}>
<Text style={styles.text}>{taps}</Text>
<Text style={styles.hint}>Тап +1 · Подвійний тап 0</Text>
</View>
</GestureDetector>
);
}
.runOnJS(true) — важливий метод. За замовчуванням колбеки RNGH виконуються на нативному UI потоці для максимальної продуктивності. Але setState, API calls та більшість JS коду повинні виконуватись у JS потоці — тому для них потрібен runOnJS(true).Pan жест: drag та swipe
Основи PanGesture
Gesture.Pan() — найуживаніший жест для drag-and-drop, pull-to-refresh, bottom sheet і swipe-дій. Він надає:
translationX,translationY— зміщення від початкової точки (скидається на 0 кожен раз)changeX,changeY— зміщення з попереднього викликуonChangevelocityX,velocityY— поточна швидкість у пікселях/сabsoluteX,absoluteY— абсолютні координати на екрані
Drag-and-drop елемента
Найпростіший drag: переміщення View пальцем. Використовуємо Animated.Value для синхронного оновлення позиції:
import { useRef } from 'react';
import { Animated, View, StyleSheet } from 'react-native';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
export function DraggableBox() {
const offsetX = useRef(new Animated.Value(0)).current;
const offsetY = useRef(new Animated.Value(0)).current;
// Зберігаємо позицію між жестами
const startX = useRef(0);
const startY = useRef(0);
const pan = Gesture.Pan()
.runOnJS(true)
.onBegin(() => {
// Запам'ятовуємо поточну позицію
startX.current = (offsetX as any)._value;
startY.current = (offsetY as any)._value;
})
.onChange((event) => {
offsetX.setValue(startX.current + event.translationX);
offsetY.setValue(startY.current + event.translationY);
});
return (
<GestureDetector gesture={pan}>
<Animated.View
style={[
styles.box,
{ transform: [{ translateX: offsetX }, { translateY: offsetY }] },
]}
/>
</GestureDetector>
);
}
const styles = StyleSheet.create({
box: { width: 80, height: 80, borderRadius: 16, backgroundColor: '#3B82F6' },
});
Animated.Value — вони оновлюються прямо на UI потоці без будь-якого bridge, що дає ідеально плавний рух. Реanimated детальніше розглядається у наступному розділі.Живий приклад: перетягуваний елемент
Loading…
react-native-web · not a real device
import { useRef } from 'react';
import { Animated, View, Text, StyleSheet, PanResponder } from 'react-native';
export default function App() {
const pan = useRef(new Animated.ValueXY()).current;
const offset = useRef({ x: 0, y: 0 });
const panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
pan.setOffset({ x: offset.current.x, y: offset.current.y });
pan.setValue({ x: 0, y: 0 });
},
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }], {
useNativeDriver: false,
}),
onPanResponderRelease: () => {
offset.current = { x: (pan.x as any)._value, y: (pan.y as any)._value };
pan.flattenOffset();
},
});
return (
<View style={styles.container}>
<Text style={styles.hint}>Перетягни квадрат</Text>
<Animated.View
style={[styles.box, { transform: pan.getTranslateTransform() }]}
{...panResponder.panHandlers}
>
<Text style={styles.emoji}>👆</Text>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center' },
hint: { marginBottom: 24, color: '#64748B', fontSize: 15 },
box: {
width: 90, height: 90, borderRadius: 20,
backgroundColor: '#3B82F6',
alignItems: 'center', justifyContent: 'center',
shadowColor: '#3B82F6', shadowOpacity: 0.4, shadowRadius: 12, elevation: 8,
},
emoji: { fontSize: 32 },
});
Налаштування порогів Pan жесту
-[-20, 20] — жест почне лише якщо рух > 20px по X.1 — тільки один палець (запобігає конфлікту з pinch).Комбінація жестів: Simultaneous, Exclusive, Race
RNGH надає три оператори для комбінування жестів:
Gesture.Simultaneous(...)
Усі жести активні одночасно. Використовується коли потрібно і масштабувати, і обертати одночасно:
const composed = Gesture.Simultaneous(pinchGesture, rotationGesture);
Gesture.Exclusive(...)
Активується перший розпізнаний жест; решта скасовуються. Порядок важливий — спочатку перевіряється перший аргумент:
// Спочатку подвійний тап, потім одинарний
const composed = Gesture.Exclusive(doubleTap, singleTap);
Gesture.Race(...)
«Гонка» — перший жест, що розпізнається, «перемагає» і скасовує решту. Відрізняється від Exclusive тим, що не має жорсткого пріоритету:
const composed = Gesture.Race(panGesture, swipeGesture);
Взаємодія з ScrollView
Класична проблема: swipe-елемент всередині ScrollView перехоплюється скролом. RNGH вирішує це через simultaneousWithExternalGesture:
import { ScrollView } from 'react-native-gesture-handler'; // <- RNGH ScrollView!
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import { useRef } from 'react';
function SwipeableList() {
const scrollRef = useRef(null);
const swipeGesture = Gesture.Pan()
.activeOffsetX([-10, 10]) // активується лише при горизонтальному русі
.failOffsetY([-5, 5]); // провалюється при вертикальному
return (
// Важливо: ScrollView з react-native-gesture-handler
<ScrollView ref={scrollRef}>
{items.map((item) => (
<GestureDetector key={item.id} gesture={swipeGesture}>
<SwipeableRow item={item} />
</GestureDetector>
))}
</ScrollView>
);
}
ScrollView та FlatList використовуйте їхні версії з react-native-gesture-handler, а не з react-native. Вони правильно передають жести дочірнім RNGH-компонентам.Haptic Feedback: тактильний відгук
Чому haptics важливі для UX
Haptic feedback — вібрація тактильного двигуна (Taptic Engine на iPhone, HAL на Android) — це один з найефективніших, але найнедооцінених інструментів мобільного UX. Дослідження показують, що правильно підібраний тактильний відгук:
- Підтверджує дію без візуального відволікання (користувач знає, що натискання зараховано, не дивлячись на екран).
- Попереджає про помилку — виразне тремтіння при неправильному PIN-коді зрозуміліше ніж текст.
- Передає фізичний контекст — «клік» при переключенні тумблера робить UI відчутним.
У людини є тактильні рецептори двох типів, що реагують на різні частоти. Taptic Engine iPhone генерує точно підібрані вібраційні патерни під кожен тип feedback.
expo-haptics
Expo надає крос-платформний API для haptics:
npx expo install expo-haptics
Типи тактильного feedback
style:ImpactFeedbackStyle.Light— легкий дотик, для дрібних елементівImpactFeedbackStyle.Medium— середній, найуніверсальніший (кнопки, вибір елемента)ImpactFeedbackStyle.Heavy— сильний, для важливих дійImpactFeedbackStyle.Rigid— різкий і жорсткий (iOS 13+)ImpactFeedbackStyle.Soft— м'який (iOS 13+)
type:NotificationFeedbackType.Success— подвійний легкий імпульс (✅ збереження, відправлення)NotificationFeedbackType.Warning— потрійний наростаючий (⚠️ попередження)NotificationFeedbackType.Error— різке тремтіння (❌ помилка, неправильний пароль)
Правильне застосування типів
| Сценарій | Тип haptic |
|---|---|
| Кнопка «Відправити» | Impact.Medium при onPressIn |
| Кнопка «Видалити» | Impact.Heavy + Notification.Warning |
| Свайп-delete підтвердження | Notification.Success |
| Неправильний PIN | Notification.Error |
| Переміщення слайдера | Selection при кожному кроці |
| Toggle Switch | Impact.Light |
| Pull-to-refresh | Impact.Medium при досягненні порогу |
| Довгий тап | Impact.Medium при onLongPress |
Кнопка з haptic feedback
import * as Haptics from 'expo-haptics';
import { Pressable, Text, StyleSheet } from 'react-native';
interface HapticButtonProps {
onPress: () => void;
label: string;
variant?: 'default' | 'danger';
}
export function HapticButton({ onPress, label, variant = 'default' }: HapticButtonProps) {
const handlePressIn = async () => {
// Миттєвий тактильний відгук ще до відпускання кнопки
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
};
const handlePress = async () => {
if (variant === 'danger') {
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
}
onPress();
};
return (
<Pressable
onPressIn={handlePressIn}
onPress={handlePress}
style={({ pressed }) => [
styles.button,
variant === 'danger' && styles.dangerButton,
pressed && styles.pressed,
]}
>
<Text style={styles.label}>{label}</Text>
</Pressable>
);
}
impactAsync у onPressIn, а не onPress. Це дає тактильний відгук в момент дотику, а не після відпускання — що значно природніше для користувача.Haptics і accessibility
На iOS у налаштуваннях є «Reduce Motion» і вимкнення тактильних функцій. expo-haptics автоматично ігнорує виклики, якщо користувач вимкнув haptics у системі. Жодної додаткової перевірки не потрібно.
На Android підтримка залежить від моделі пристрою і версії ОС. Деякі бюджетні пристрої мають лише базовий мотор без диференціювання типів — вони все одно вібрують, але однаково.
Свайп-дії: swipe-to-delete
Концепція компонента
Swipe-to-delete — один з найпоширеніших патернів мобільних застосунків (Gmail, iOS Messages, Telegram). Він складається з:
- Верхній шар (foreground) — видимий контент рядка.
- Нижній шар (background) — прихована зона з кнопками дій (Видалити, Архів тощо).
- Pan жест — переміщення верхнього шару ліворуч відкриває нижній.
- Поріг спрацювання — якщо рух перевищує певну відстань → автоматичне видалення.
- Снекбар Undo — «Скасувати видалення» протягом кількох секунд.
Swipe-to-delete з Animated API
Реалізуємо без Reanimated (лише react-native Animated + RNGH Pan):
// components/SwipeableRow.tsx
import { useRef, useCallback } from 'react';
import { Animated, View, Text, Pressable, StyleSheet, Dimensions } from 'react-native';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
import * as Haptics from 'expo-haptics';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
const DELETE_THRESHOLD = SCREEN_WIDTH * 0.7;
const ACTION_WIDTH = 80;
interface Props {
item: { id: string; title: string };
onDelete: (id: string) => void;
}
export function SwipeableRow({ item, onDelete }: Props) {
const translateX = useRef(new Animated.Value(0)).current;
const startX = useRef(0);
const isThresholdReached = useRef(false);
const pan = Gesture.Pan()
.runOnJS(true)
.activeOffsetX([-5, 5]) // горизонтальний жест
.failOffsetY([-10, 10]) // провалюється при вертикалі
.onBegin(() => {
startX.current = (translateX as any)._value;
})
.onChange((event) => {
const newX = Math.min(0, startX.current + event.translationX);
translateX.setValue(newX);
// Haptic при перетині порогу
const threshold = -DELETE_THRESHOLD;
if (newX < threshold && !isThresholdReached.current) {
isThresholdReached.current = true;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
} else if (newX > threshold && isThresholdReached.current) {
isThresholdReached.current = false;
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
})
.onEnd((event) => {
const currentX = (translateX as any)._value;
if (currentX < -DELETE_THRESHOLD) {
// Видалення: анімуємо за межі екрана
Animated.timing(translateX, {
toValue: -SCREEN_WIDTH,
duration: 200,
useNativeDriver: true,
}).start(() => {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
onDelete(item.id);
});
} else if (currentX < -ACTION_WIDTH / 2) {
// Зупиняємось на ACTION_WIDTH (показуємо кнопку Delete)
Animated.spring(translateX, {
toValue: -ACTION_WIDTH,
useNativeDriver: true,
}).start();
} else {
// Повертаємось
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: true,
}).start();
}
isThresholdReached.current = false;
});
const deleteStyle = {
transform: [{ translateX }],
};
// Прогрес для забарвлення фону
const bgColor = translateX.interpolate({
inputRange: [-DELETE_THRESHOLD, -ACTION_WIDTH, 0],
outputRange: ['#EF4444', '#EF4444', '#F87171'],
extrapolate: 'clamp',
});
return (
<View style={styles.container}>
{/* Фоновий шар */}
<Animated.View style={[styles.background, { backgroundColor: bgColor }]}>
<Text style={styles.deleteLabel}>🗑</Text>
</Animated.View>
{/* Foreground */}
<GestureDetector gesture={pan}>
<Animated.View style={[styles.row, deleteStyle]}>
<Text style={styles.title}>{item.title}</Text>
</Animated.View>
</GestureDetector>
</View>
);
}
Undo Snackbar
Снекбар «Скасувати» — обов'язкова частина деструктивних дій. Надає користувачу вікно безпеки:
// components/UndoSnackbar.tsx
import { useEffect, useRef } from 'react';
import { Animated, Pressable, Text, StyleSheet } from 'react-native';
interface Props {
message: string;
onUndo: () => void;
onDismiss: () => void;
duration?: number;
}
export function UndoSnackbar({ message, onUndo, onDismiss, duration = 3000 }: Props) {
const opacity = useRef(new Animated.Value(0)).current;
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
// Анімація появи
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}).start();
// Автозакриття
timerRef.current = setTimeout(() => {
dismiss();
}, duration);
return () => clearTimeout(timerRef.current);
}, []);
const dismiss = () => {
Animated.timing(opacity, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}).start(onDismiss);
};
const handleUndo = () => {
clearTimeout(timerRef.current);
dismiss();
onUndo();
};
return (
<Animated.View style={[styles.container, { opacity }]}>
<Text style={styles.message}>{message}</Text>
<Pressable onPress={handleUndo} style={styles.undoBtn}>
<Text style={styles.undoText}>Скасувати</Text>
</Pressable>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
bottom: 24,
left: 16,
right: 16,
backgroundColor: '#1E293B',
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
},
message: { color: 'white', fontSize: 14 },
undoBtn: { marginLeft: 16 },
undoText: { color: '#60A5FA', fontWeight: '700', fontSize: 14 },
});
Довгий тап і контекстне меню
LongPressGesture
Gesture.LongPress() розпізнає утримання пальця на місці. Ідеально для контекстного меню або «режиму вибору»:
const longPress = Gesture.LongPress()
.minDuration(500) // мс до спрацювання (дефолт: 500)
.maxDistance(10) // максимальне переміщення пальця при утриманні (пікселі)
.runOnJS(true)
.onStart((event) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
setShowContextMenu(true);
setMenuPosition({ x: event.absoluteX, y: event.absoluteY });
});
iOS-стиль Context Menu через ActionSheetIOS
На iOS є системний контекстний лист — ActionSheetIOS:
import { ActionSheetIOS, Platform } from 'react-native';
const showContextMenu = () => {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ['Скасувати', 'Редагувати', 'Поділитись', 'Видалити'],
cancelButtonIndex: 0,
destructiveButtonIndex: 3,
title: 'Що зробити з елементом?',
},
(buttonIndex) => {
if (buttonIndex === 1) handleEdit();
if (buttonIndex === 2) handleShare();
if (buttonIndex === 3) handleDelete();
}
);
} else {
// На Android показуємо власний Modal
setShowModal(true);
}
};
Pinch та Rotation: масштабування і обертання
Спільна реалізація pinch + rotation
Класичний приклад — масштабування і обертання зображення двома пальцями (як у Photos.app):
import { useRef } from 'react';
import { Animated, Image, StyleSheet, View } from 'react-native';
import { GestureDetector, Gesture } from 'react-native-gesture-handler';
export function InteractiveImage({ source }: { source: any }) {
const scale = useRef(new Animated.Value(1)).current;
const rotation = useRef(new Animated.Value(0)).current;
const savedScale = useRef(1);
const savedRotation = useRef(0);
const pinch = Gesture.Pinch()
.runOnJS(true)
.onUpdate((event) => {
scale.setValue(savedScale.current * event.scale);
})
.onEnd((event) => {
savedScale.current = savedScale.current * event.scale;
});
const rotate = Gesture.Rotation()
.runOnJS(true)
.onUpdate((event) => {
rotation.setValue(savedRotation.current + event.rotation);
})
.onEnd((event) => {
savedRotation.current = savedRotation.current + event.rotation;
});
// Одночасне масштабування і обертання
const composed = Gesture.Simultaneous(pinch, rotate);
// Конвертуємо радіани у градуси для CSS rotate
const rotateStr = rotation.interpolate({
inputRange: [-Math.PI * 2, Math.PI * 2],
outputRange: ['-360deg', '360deg'],
});
return (
<GestureDetector gesture={composed}>
<Animated.Image
source={source}
style={[
styles.image,
{
transform: [
{ scale },
{ rotate: rotateStr },
],
},
]}
/>
</GestureDetector>
);
}
const styles = StyleSheet.create({
image: { width: 200, height: 200, borderRadius: 16 },
});
Живий приклад: zoom зображення
Loading…
react-native-web · not a real device
import { useRef, useState } from 'react';
import { Animated, View, Text, PanResponder, StyleSheet } from 'react-native';
export default function App() {
const scale = useRef(new Animated.Value(1)).current;
const [info, setInfo] = useState('Зведи два пальці для масштабування');
// PanResponder для мультитач (react-native-web не підтримує RNGH)
let lastDistance = useRef(0);
let currentScale = useRef(1);
const panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: (e) => {
if (e.nativeEvent.touches.length === 2) {
const t = e.nativeEvent.touches;
const dx = t[0].pageX - t[1].pageX;
const dy = t[0].pageY - t[1].pageY;
lastDistance.current = Math.sqrt(dx * dx + dy * dy);
}
},
onPanResponderMove: (e) => {
if (e.nativeEvent.touches.length === 2) {
const t = e.nativeEvent.touches;
const dx = t[0].pageX - t[1].pageX;
const dy = t[0].pageY - t[1].pageY;
const dist = Math.sqrt(dx * dx + dy * dy);
const newScale = Math.min(3, Math.max(0.5, currentScale.current * (dist / lastDistance.current)));
scale.setValue(newScale);
setInfo(`Scale: ${newScale.toFixed(2)}x`);
}
},
onPanResponderRelease: () => {
currentScale.current = (scale as any)._value;
},
});
return (
<View style={styles.container}>
<Animated.View
style={[styles.box, { transform: [{ scale }] }]}
{...panResponder.panHandlers}
>
<Text style={styles.emoji}>🗺️</Text>
</Animated.View>
<Text style={styles.info}>{info}</Text>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 24 },
box: {
width: 120, height: 120, borderRadius: 24,
backgroundColor: '#3B82F6',
alignItems: 'center', justifyContent: 'center',
},
emoji: { fontSize: 56 },
info: { color: '#64748B', fontSize: 14 },
});
Міні-проєкт: «Список з swipe-delete»
Що будуємо
Список з 10 елементами де:
- Свайп ліворуч відкриває червону зону «Видалити».
- Продовження свайпу > 70% ширини → автоматичне видалення з анімацією.
- Haptic feedback при перетині порогу.
- Undo Snackbar «Елемент видалено · Скасувати» з таймером 3 секунди.
- Довгий тап → ActionSheet з опціями «Редагувати»/«Видалити»/«Поділитись».
Структура файлів
Хук useUndoDelete
// hooks/useUndoDelete.ts
import { useState, useRef, useCallback } from 'react';
import * as Haptics from 'expo-haptics';
interface UndoState<T> {
deletedItem: T | null;
deletedIndex: number;
}
export function useUndoDelete<T extends { id: string }>(
items: T[],
setItems: React.Dispatch<React.SetStateAction<T[]>>
) {
const [undoState, setUndoState] = useState<UndoState<T>>({
deletedItem: null,
deletedIndex: -1,
});
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const deleteItem = useCallback((id: string) => {
const index = items.findIndex((i) => i.id === id);
const item = items[index];
if (!item) return;
// Видаляємо зі списку
setItems((prev) => prev.filter((i) => i.id !== id));
setUndoState({ deletedItem: item, deletedIndex: index });
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Таймер кінцевого видалення (тут можна викликати API)
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setUndoState({ deletedItem: null, deletedIndex: -1 });
// TODO: api.deleteItem(id)
}, 3000);
}, [items, setItems]);
const undoDelete = useCallback(() => {
clearTimeout(timerRef.current);
if (!undoState.deletedItem) return;
// Повертаємо елемент на місце
setItems((prev) => {
const newItems = [...prev];
newItems.splice(undoState.deletedIndex, 0, undoState.deletedItem!);
return newItems;
});
setUndoState({ deletedItem: null, deletedIndex: -1 });
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, [undoState, setItems]);
const dismissUndo = useCallback(() => {
setUndoState({ deletedItem: null, deletedIndex: -1 });
}, []);
return {
deleteItem,
undoDelete,
dismissUndo,
showUndo: undoState.deletedItem !== null,
};
}
Головний екран
// app/(tabs)/list.tsx
import { useState } from 'react';
import { FlatList, View, StyleSheet, SafeAreaView } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SwipeableRow } from '@/components/list/SwipeableRow';
import { UndoSnackbar } from '@/components/list/UndoSnackbar';
import { useUndoDelete } from '@/hooks/useUndoDelete';
const INITIAL_ITEMS = Array.from({ length: 10 }, (_, i) => ({
id: String(i + 1),
title: `Завдання #${i + 1}`,
subtitle: `Опис завдання номер ${i + 1}`,
}));
export default function ListScreen() {
const [items, setItems] = useState(INITIAL_ITEMS);
const { deleteItem, undoDelete, dismissUndo, showUndo } = useUndoDelete(items, setItems);
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaView style={styles.container}>
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<SwipeableRow
item={item}
onDelete={() => deleteItem(item.id)}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.list}
/>
{showUndo && (
<UndoSnackbar
message="Елемент видалено"
onUndo={undoDelete}
onDismiss={dismissUndo}
duration={3000}
/>
)}
</SafeAreaView>
</GestureHandlerRootView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F8FAFC' },
list: { padding: 16, gap: 2 },
separator: { height: 1, backgroundColor: '#E2E8F0' },
});
Підключення до Nomad: feat: press gestures and haptics
У Nomad жести природно з'являються у кількох місцях:
Список поїздок
Список місць у поїздці
onLongPress.Форма додавання
Impact.Medium при натисканні кнопки «Зберегти», Notification.Success після успішного збереження, Notification.Error при помилці валідації.Поширені помилки
<GestureHandlerRootView style={{ flex: 1 }}> у app/_layout.tsx. Без нього RNGH мовчки ігнорує всі жести.ScrollView з react-native-gesture-handler, а не з react-native. Або налаштуйте activeOffsetX і failOffsetY на Pan жесті для чіткого розмежування горизонтального і вертикального руху.setState без runOnJS(true) спричинить Runtime Error. Завжди додавайте .runOnJS(true) коли потрібно оновлювати React стейт.Gesture.Exclusive(doubleTap, singleTap). Без цього RNGH не чекає другого тапу і відразу спрацьовує onTap після першого. Порядок у Exclusive важливий: спочатку подвійний.LongPress.onStart(), а потім активувати Pan. Використовуйте Gesture.Simultaneous(longPress, pan) і перевіряйте в Pan, чи активний LongPress.Підсумок
Touch Pipeline
- Responder System: capture → bubble → negotiate
- RNGH виконується на нативному UI потоці (JSI)
GestureHandlerRootViewобов'язковий у корені
Pressable
- Замінює
TouchableOpacityіTouchableHighlight style={({ pressed }) => ...}для динамічних стилівonPressInдля миттєвого feedbackhitSlopдля розширення зони торкання
RNGH Gesture API
Gesture.Tap(),Pan(),Pinch(),Rotation(),LongPress()Gesture.Simultaneous()/Exclusive()/Race()для комбінацій.runOnJS(true)обов'язковий дляsetState
Haptics та Swipe UX
ImpactприonPressIn,Notificationпісля результату- Swipe-delete: foreground + background шари + поріг
- Undo Snackbar для безпеки деструктивних дій
Карти та геолокація
Робота з GPS-координатами, відображення мап через react-native-maps, маркери, кластеризація та обробка відмови дозволів у React Native / Expo
Анімації з Reanimated
Shared values, worklets, springs, layout animations та reduced motion у React Native Reanimated 3 — від теорії до плавного UI