Реализация бесконечной прокрутки (Infinite Scroll) в мобильном приложении
Infinite scroll реализован в каждом втором приложении и сломан примерно в половине из них. Дублирующиеся запросы при достижении конца списка, спиннер, который показывается бесконечно после ошибки сети, или список, который скачет назад при добавлении новых элементов — всё это следствия одних и тех же технических ошибок.
Главная проблема: множественные вызовы onEndReached
FlatList.onEndReached в React Native срабатывает не один раз — он может вызваться несколько раз подряд при быстром скролле, при первом рендере если контент меньше экрана, при изменении высоты компонента. Защита:
const isLoadingMore = useRef(false);
const handleEndReached = useCallback(() => {
if (isLoadingMore.current || !hasNextPage) return;
isLoadingMore.current = true;
fetchNextPage().finally(() => {
isLoadingMore.current = false;
});
}, [hasNextPage, fetchNextPage]);
useRef вместо useState — потому что useState не успевает обновиться между двумя синхронными вызовами onEndReached.
onEndReachedThreshold={0.3} — начинаем загрузку, когда до конца осталось 30% от высоты списка. При 0.1 пользователь видит спиннер прежде чем данные загрузятся. При 0.5 — данные подгружаются слишком рано и расходуется лишний трафик.
Cursor-based vs offset пагинация
Offset-based (?page=2&limit=20) ломается при параллельном добавлении новых элементов: страница 2 сдвигается и пользователь либо пропускает элементы, либо видит дубликаты. Cursor-based (?after=cursor_value) стабилен — каждый запрос начинается строго с последнего полученного элемента.
Если API отдаёт только offset — реализуем client-side дедупликацию по id:
const uniqueItems = [...existingItems, ...newItems].filter(
(item, index, arr) => arr.findIndex(i => i.id === item.id) === index
);
На Flutter — ScrollController с addListener:
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMore();
}
});
Альтернатива — package:infinite_scroll_pagination (pub.dev). Управляет состоянием пагинации, ошибками и retry из коробки. PagingController + PagedListView — минимум бойлерплейта.
На Android с Compose — LazyListState.firstVisibleItemScrollOffset + derivedStateOf:
val shouldLoadMore by remember {
derivedStateOf {
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
lastVisibleIndex >= items.size - 5 && !isLoading
}
}
derivedStateOf гарантирует, что recomposition происходит только когда shouldLoadMore реально меняется, а не при каждом пикселе скролла.
Обработка состояний
Infinite scroll требует корректной обработки четырёх состояний:
| Состояние | UI |
|---|---|
| Первичная загрузка | Skeleton list (не spinner по центру экрана) |
| Загрузка следующей страницы | Footer с CircularProgressIndicator |
| Ошибка загрузки следующей страницы | Footer с текстом ошибки + кнопка «Повторить» |
| Все данные загружены | Footer «Больше нет элементов» или ничего |
Footer-компонент добавляется как ListFooterComponent в FlatList или через itemCount: items.length + (state != DONE ? 1 : 0) в Compose.
Из практики: социальное приложение, Flutter. Лента из 1000+ постов. Жалобы на дублирующиеся посты. Оказалось: при быстром скролле _loadMore() вызывался 3–4 раза параллельно до получения первого ответа. Cursor не обновлялся — каждый запрос уходил с одним курсором. Добавили bool _isLoading флаг + ранний return — дубликаты исчезли.
Скролл к началу
При появлении новых элементов в реалтайм-ленте не прыгаем к новым постам принудительно. Показываем плавающую кнопку «N новых записей» — пользователь сам решает, когда прокрутить наверх. scrollToIndex(0) через listRef в RN или animateScrollTo(0) в Flutter.
Что входит в работу
- Infinite scroll с cursor-based или offset пагинацией
- Защита от множественных запросов
- Footer: спиннер / ошибка с retry / конец списка
- Skeleton-загрузка для первой страницы
- Client-side дедупликация при необходимости
- Кнопка «Новые элементы» для реалтайм-лент
Сроки
1–3 рабочих дня в зависимости от сложности типов элементов и требований к обработке ошибок. Стоимость рассчитывается индивидуально.







