Ниже — несколько практичных подходов и готовый рабочий пример для 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), и пришлите ваш текущий код целиком — сделаю конкретную интеграцию под ваш экран.