Gesture Handler — surish, tortish, swipe-to-delete

~20 daq · React Native · Animatsiya va UX

Muammodan boshlaylik

Mobil foydalanuvchi faqat bosmaydi — u suradi, tortadi, chimchilaydi, ikki marta bosadi:

  • Xatni chapga surib o'chirish (Gmail, Telegram)
  • Rasmni ikki barmoq bilan kattalashtirish
  • Kartani surib tashlash (Tinder)
  • Bottom sheet ni barmoq bilan yuqoriga tortish

Pressable faqat bosishni biladi. Murakkab imo-ishoralar uchun — React Native Gesture Handler, Reanimated bilan birga.


O'rnatish

Expo shablonida bor. Bo'lmasa:

npx expo install react-native-gesture-handler

Ildizni o'rash (Expo Router da odatda allaqachon):

// app/_layout.tsx
import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function RootLayout() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <Stack />
    </GestureHandlerRootView>
  );
}

Asosiy tuzilma

import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';

export function SuriladiganQuti() {
  const x = useSharedValue(0);
  const y = useSharedValue(0);

  const surish = Gesture.Pan()
    .onUpdate((e) => {
      x.value = e.translationX;
      y.value = e.translationY;
    })
    .onEnd(() => {
      x.value = withSpring(0);       // qo'yib yuborilganda — joyiga qaytadi
      y.value = withSpring(0);
    });

  const stil = useAnimatedStyle(() => ({
    transform: [{ translateX: x.value }, { translateY: y.value }],
  }));

  return (
    <GestureDetector gesture={surish}>
      <Animated.View style={[{ width: 100, height: 100, backgroundColor: '#0284c7', borderRadius: 16 }, stil]} />
    </GestureDetector>
  );
}
  • Gesture.Pan() — surish imo-ishorasi
  • .onUpdate — barmoq harakatlanayotganda (worklet — UI thread da, 45-dars)
  • .onEnd — barmoq qo'yib yuborilganda
  • GestureDetector — imo-ishorani elementga ulaydi

Quti barmoq orqasidan kechikishsiz ergashadi — chunki hammasi UI thread da.


Imo-ishora turlari

Gesture Nima Hodisa ma'lumoti
Gesture.Tap() Bosish; .numberOfTaps(2) — ikki marta —
Gesture.LongPress() Uzoq bosish duration
Gesture.Pan() Surish, tortish translationX/Y, velocityX/Y
Gesture.Pinch() Ikki barmoq bilan masshtab scale
Gesture.Rotation() Ikki barmoq bilan burish rotation
Gesture.Fling() Tez "otish" yo'nalish

Birlashtirish

const ikkiMarta = Gesture.Tap().numberOfTaps(2).onEnd(() => { masshtab.value = withSpring(2); });
const birMarta = Gesture.Tap().onEnd(() => { runOnJS(ochish)(); });

// Ikki marta bosish ustun; u bo'lmasa — bir marta
const bosish = Gesture.Exclusive(ikkiMarta, birMarta);

// Pinch va Pan bir vaqtda — rasmni kattalashtirib, surish
const rasm = Gesture.Simultaneous(pinch, pan);

Swipe-to-delete

Eng ko'p so'raladigan imo-ishora:

import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { useSharedValue, useAnimatedStyle, withTiming, runOnJS, interpolate, FadeOut, LinearTransition } from 'react-native-reanimated';
import * as Haptics from 'expo-haptics';

const CHEGARA = -100;

export function SuriladiganBand({ matn, ochir }: { matn: string; ochir: () => void }) {
  const x = useSharedValue(0);

  const surish = Gesture.Pan()
    .activeOffsetX([-10, 10])        // gorizontal 10 dan ko'p surilganda boshlansin
    .failOffsetY([-10, 10])          // vertikal surish — ro'yxat scroll i uchun qoldirilsin
    .onUpdate((e) => {
      x.value = Math.min(0, e.translationX);      // faqat chapga
    })
    .onEnd(() => {
      if (x.value < CHEGARA) {
        x.value = withTiming(-500, { duration: 200 }, () => runOnJS(ochir)());
        runOnJS(Haptics.impactAsync)(Haptics.ImpactFeedbackStyle.Medium);
      } else {
        x.value = withTiming(0);
      }
    });

  const bandStil = useAnimatedStyle(() => ({ transform: [{ translateX: x.value }] }));
  const ikonkaStil = useAnimatedStyle(() => ({
    opacity: interpolate(x.value, [0, CHEGARA], [0, 1]),
    transform: [{ scale: interpolate(x.value, [0, CHEGARA], [0.5, 1], 'clamp') }],
  }));

  return (
    <Animated.View exiting={FadeOut} layout={LinearTransition}>
      {/* Orqadagi qizil fon */}
      <View style={{ ...StyleSheet.absoluteFillObject, backgroundColor: '#dc2626', justifyContent: 'center', alignItems: 'flex-end', paddingRight: 24 }}>
        <Animated.View style={ikonkaStil}>
          <Ionicons name="trash" size={24} color="white" />
        </Animated.View>
      </View>

      {/* Suriladigan qism */}
      <GestureDetector gesture={surish}>
        <Animated.View style={[{ backgroundColor: 'white', padding: 18 }, bandStil]}>
          <Text style={{ fontSize: 16 }}>{matn}</Text>
        </Animated.View>
      </GestureDetector>
    </Animated.View>
  );
}

Muhim jihatlar:

  • activeOffsetX / failOffsetY — eng nozik joy. Busiz band ro'yxatning vertikal scroll ini "o'g'irlaydi" va ro'yxat aylanmaydi. Gorizontal harakat — surish, vertikal — scroll
  • Chegara — 100 birlikdan ko'p surilsa o'chadi, kam bo'lsa — qaytadi
  • Ikonka asta paydo bo'ladi va kattalashadi — foydalanuvchi qancha surish kerakligini his qiladi
  • runOnJS(ochir) — holatni o'zgartirish JS da
  • exiting + layout — o'chirilgan band yo'qoladi, qolganlar silliq yuqoriga siljiydi

[!TIP] Tayyor variant: react-native-gesture-handler dagi ReanimatedSwipeable komponenti — chap/o'ng amallar tugmalari bilan. O'z dizayningiz kerak bo'lmasa — shuni ishlating.


Pinch-to-zoom — rasm

const masshtab = useSharedValue(1);
const saqlangan = useSharedValue(1);

const pinch = Gesture.Pinch()
  .onUpdate((e) => { masshtab.value = saqlangan.value * e.scale; })
  .onEnd(() => {
    if (masshtab.value < 1) masshtab.value = withSpring(1);
    else if (masshtab.value > 4) masshtab.value = withSpring(4);
    saqlangan.value = Math.min(Math.max(masshtab.value, 1), 4);
  });

const ikkiMarta = Gesture.Tap().numberOfTaps(2).onEnd(() => {
  const yangi = masshtab.value > 1 ? 1 : 2.5;
  masshtab.value = withSpring(yangi);
  saqlangan.value = yangi;
});

const stil = useAnimatedStyle(() => ({ transform: [{ scale: masshtab.value }] }));

<GestureDetector gesture={Gesture.Simultaneous(pinch, ikkiMarta)}>
  <Animated.Image source={{ uri }} style={[{ width: '100%', aspectRatio: 1 }, stil]} />
</GestureDetector>

saqlangan — oldingi pinch natijasi: ikkinchi pinch birinchisi to'xtagan joydan davom etadi.


Bottom sheet

O'z bottom sheet ingizni yasash — chegaralar, tezlik (velocityY) bo'yicha yopish, ichidagi scroll bilan nizolar... Bu murakkab. Tayyor kutubxona — @gorhom/bottom-sheet (Reanimated + Gesture Handler ustida):

<BottomSheet snapPoints={['25%', '60%', '90%']} enablePanDownToClose>
  <BottomSheetView>{/* kontent */}</BottomSheetView>
</BottomSheet>

Uch balandlikda "yopishadi", pastga tortib yopiladi.


UX qoidalari

  • Imo-ishora — qo'shimcha yo'l. Har bir amal uchun ko'rinadigan muqobil bo'lsin (o'chirish — uzoq bosish menyusida ham). Imo-ishoralar yashirin, hamma ularni bilmaydi
  • Tizim imo-ishoralari bilan to'qnashmang: ekranning chap cheti — iOS da "orqaga", pastki chet — uy
  • Har bir harakatga javob: element barmoq orqasidan ergashsin, chegara o'tilganda — tebranish
  • Tezlikni hisobga oling: tez "otish" (velocityX) — qisqa masofada ham amalni bajarsin

Nima uchun shunday?

Nima uchun React Native ning o'z imo-ishora tizimi (PanResponder) emas, alohida kutubxona?

PanResponder — JavaScript thread da ishlaydi: har bir barmoq harakati native dan JS ga yuboriladi, JS hisoblaydi va natijani qaytaradi. JS band bo'lsa — element barmoqdan orqada qoladi, va bu darhol seziladi. Gesture Handler esa imo-ishoralarni native darajada, platformaning o'z mexanizmlari bilan taniydi va Reanimated bilan birga hammasini UI thread da qayta ishlaydi. Natija — native ilovalardagi kabi barmoqqa "yopishgan" harakat va scroll bilan to'g'ri ishlash.


Xulosa

Eslab qoling:

  • GestureHandlerRootView — ildizda; GestureDetector gesture={...} — elementda
  • Gesture.Pan / Tap / LongPress / Pinch / Fling; .onUpdate, .onEnd — worklet lar
  • Reanimated useSharedValue bilan — barmoq orqasidan kechikishsiz
  • Exclusive, Simultaneous — birlashtirish
  • Swipe-to-delete: activeOffsetX + failOffsetY — scroll bilan nizosiz; chegara; runOnJS
  • Tayyor: ReanimatedSwipeable, @gorhom/bottom-sheet
  • Imo-ishora — qo'shimcha yo'l; ko'rinadigan muqobil bo'lsin

Keyingi dars

47-dars: UX va tezlik — skeleton, memo, rasm optimallash