Как реализовать беспрерывный свайп в интерфейсе, чтобы элементы перелистывались или скроллировались без остановки?

Вопрос к мега‑гуру React Native: помогите, пожалуйста, решить одну важную насущную задачу. Ниже приведено полностью рабочее демо экрана: <br/> <pre><code class="java">import React, { useEffect, useRef } from 'react';
  • 18 июня 2026 г. 7:01
Ответы на вопрос 2
Ниже — несколько практичных подходов и готовый рабочий пример для React Native, которые решают задачу «беспрерывного» (infinite) автоскролла/свайпа. Ключевые идеи, которые обычно применяются:

- Дублировать данные (часто — 3 раза) и стартовать не с начала, а с «средней» копии, чтобы при прокрутке можно было сбросить offset на эквивалентную позицию в середине без видимого скачка.
- Запускать автоскролл циклом (requestAnimationFrame или Animated.timing с linear) и периодически выставлять scrollToOffset. Когда offset добирается до границы, немедленно корректировать на эквивалентную позицию в середине (без анимации).
- Остановка при касании/перетаскивании пользователя и возобновление после — по желанию.
- Для производительности лучше использовать FlatList/ScrollView и избегать дорогостоящих рендеров.

Пример 1 — FlatList + requestAnimationFrame (прямой и универсальный). Работает на iOS/Android, легко останавливать при касании.

Пример (функциональный компонент):

import React, { useEffect, useRef, useState } from 'react';
import { View, FlatList, Text, Dimensions, TouchableOpacity } from 'react-native';

const { width: WINDOW_WIDTH } = Dimensions.get('window');
const ITEM_WIDTH = WINDOW_WIDTH * 0.8; // ширина элемента
const SPACING = 10;
const VISIBLE_WIDTH = ITEM_WIDTH + SPACING;

const AutoScrollCarousel = ({ data, speed = 60 /* px/sec */ }) => {
  // дублируем массив 3 раза
  const items = [...data, ...data, ...data];
  const originalLen = data.length;
  const startIndex = originalLen; // стартуем с середины
  const flatListRef = useRef(null);
  const offsetRef = useRef(startIndex * VISIBLE_WIDTH);
  const rafRef = useRef(null);
  const lastTsRef = useRef(null);
  const runningRef = useRef(true);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    // после первого рендера установим начальный offset без анимации
    setTimeout(() => {
      if (flatListRef.current) {
        flatListRef.current.scrollToOffset({ offset: offsetRef.current, animated: false });
        setMounted(true);
      }
    }, 0);

    return () => {
      runningRef.current = false;
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  useEffect(() => {
    if (!mounted) return;
    runningRef.current = true;
    lastTsRef.current = null;
    const step = (ts) => {
      if (!runningRef.current) return;
      if (lastTsRef.current == null) lastTsRef.current = ts;
      const dt = (ts - lastTsRef.current) / 1000; // seconds
      lastTsRef.current = ts;
      // смещаем на dx = speed * dt
      offsetRef.current += speed * dt;
      // если дошли почти до конца — перепрыгиваем в середину (эквивалентное положение)
      const totalContentWidth = items.length * VISIBLE_WIDTH;
      const leftLimit = VISIBLE_WIDTH * 0.5;
      const rightLimit = totalContentWidth - VISIBLE_WIDTH * 0.5;
      if (offsetRef.current <= leftLimit) {
        // сдвигаем вправо на originalLen * VISIBLE_WIDTH
        offsetRef.current += originalLen * VISIBLE_WIDTH;
      } else if (offsetRef.current >= rightLimit) {
        offsetRef.current -= originalLen * VISIBLE_WIDTH;
      }
      if (flatListRef.current) {
        // без анимации — чтобы не срабатывать инерция
        flatListRef.current.scrollToOffset({ offset: offsetRef.current, animated: false });
      }
      rafRef.current = requestAnimationFrame(step);
    };
    rafRef.current = requestAnimationFrame(step);
    return () => {
      runningRef.current = false;
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, [mounted, speed, items.length]);

  // остановка при касании, возобновление по отпусканию
  const handleTouchStart = () => { runningRef.current = false; };
  const handleTouchEnd = () => { if (!runningRef.current) { runningRef.current = true; lastTsRef.current = null; rafRef.current = requestAnimationFrame((ts)=>{ lastTsRef.current = ts; rafRef.current = requestAnimationFrame(()=>{}); }); /* перезапустится useEffect */ } };

  const renderItem = ({ item, index }) => (
    <View style={{ width: ITEM_WIDTH, marginRight: SPACING, backgroundColor: '#eee', alignItems: 'center', justifyContent: 'center' }}>
      <Text>{item}</Text>
    </View>
  );

  return (
    <View onTouchStart={handleTouchStart} onTouchEnd={handleTouchEnd}>
      <FlatList
        ref={flatListRef}
        data={items}
        keyExtractor={(_, i) => String(i)}
        horizontal
        showsHorizontalScrollIndicator={false}
        renderItem={renderItem}
        getItemLayout={(_, index) => ({ length: VISIBLE_WIDTH, offset: index * VISIBLE_WIDTH, index })}
        initialScrollIndex={startIndex}
      />
    </View>
  );
};

Пояснения к коду:
- items = data * 3 — это трюк, чтобы при перепрыгивании на «эквивалентную» позицию пользователь не заметил скачка.
- offsetRef хранит текущий абсолютный offset. Мы на каждом кадре прибавляем speed * dt и вызываем scrollToOffset с animated: false.
- Когда offset близок к краям (leftLimit/rightLimit) — корректируем его, вычитая/прибавляя originalLen * VISIBLE_WIDTH, тем самым перенаправляя прокрутку в середину «логически», но позиция на экране остаётся одинаковой.
- Обработка касания: при взаимодействии пользователя автоскролл можно приостанавливать.

Пример 2 — Animated (рисуем строку элементов и двигаем её с помощью transform)
Если ваш список не очень длинный и вы хотите полностью управлять анимацией, можно отрисовать row элементов вручную, продублировать их 2–3 раза и с помощью Animated.loop и Animated.timing с easing: Easing.linear делать translateX. При достижении сдвига равного ширине блока из одной копии — сбрасывать значение и продолжать. Это даёт очень гладкую анимацию и можно использовать useNativeDriver: true.

Короткая схема:
- items = [...data, ...data];
- measure width одной копии (Wcopy = originalLen * VISIBLE_WIDTH).
- Animated.timing(translateX, { toValue: -Wcopy, duration: (Wcopy / speed) * 1000, easing: Easing.linear, useNativeDriver: true })
- .start(() => { translateX.setValue(0); /* loop */ })

Минусы: сложнее поддерживать интерактивную прокрутку руками (scroll gestures), но отличная плавность.

Практические советы и подводные камни
- Для плавной непрерывной анимации используйте linear (не spring), и либо useNativeDriver для Animated, либо requestAnimationFrame со scrollToOffset.
- На Android иногда наблюдается дрожание при очень частых вызовах scrollToOffset — уменьшайте частоту, увеличивая шаг (меньше кадров) или используйте Animated на native driver.
- Если контент тяжелый, не рендерьте всё в три раза — можно рендерить только видимые + буфер и динамически заменять контент, но это усложняет логику.
- Тормоза при user interaction: нужно отслеживать onTouchStart/onTouchEnd или onScrollBeginDrag/onScrollEndDrag, чтобы при касании пользователь мог взять контроль.
- Для «снапинга» (свайп по одному элементу) можно комбинировать с pagingEnabled / snapToInterval, но тогда непрерывный автоскролл сложнее — потребуется анимировать «перелистывания» через scrollToIndex с animated: true и таймером между перелистыванием.

Если нужно, могу:
- Подготовить полностью рабочий минимальный проект (копировать/вставить) с Animated и useNativeDriver.
- Адаптировать пример под ваш исходный код (пришлите ваш текущий рабочий файл целиком — интегрирую автоскролл туда и учту размеры/паузы/снап).
- Показать вариант с react-native-reanimated v2 (там очень плавно и эффективно делать loop анимацию).

Напишите, какой подход вам ближе (FlatList + requestAnimationFrame или Animated с useNativeDriver / reanimated), и пришлите ваш текущий код целиком — сделаю конкретную интеграцию под ваш экран.
PanResponder не даст сделать нормальный handoff — он захватывает жест и ScrollView внутри уже не включится без нового касания. Это задача для @gorhom/bottom-sheet: внутри шита подмени ScrollView на BottomSheetScrollView, и он сам разруливает когда тянуть панель, а когда скроллить содержимое. Без отрыва пальца.
Похожие вопросы