Реалізація бесконечної прокрутки в мобільному додатку
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 (не спіннер по центру екрана) |
| Завантаження наступної сторінки | Footer з CircularProgressIndicator |
| Помилка завантаження наступної сторінки | Footer з текстом помилки + кнопка «Повторити» |
| Всі дані завантажені | Footer «Більше немає елементів» або нічого |
Footer-компонент додається як ListFooterComponent у FlatList чи через itemCount: items.length + (state != DONE ? 1 : 0) у Compose.
З практики: соціальний додаток, Flutter. Стрічка з 1000+ постів. Скарги на дублюючи посты. Виявилось: при швидкому скролі _loadMore() викликалась 3–4 рази паралельно до отримання першої відповіді. Курсор не оновлювався — кожен запрос уходил з одним курсором. Додали bool _isLoading флаг + ранній return — дублікати зникли.
Скролл до початку
При появі нових елементів у реалтайм-стрічці не прибиваємо до нових постів примусово. Показуємо плаваючу кнопку «N нових записів» — користувач сам вирішує, коли прокрутити наверх. scrollToIndex(0) через listRef у RN чи animateScrollTo(0) у Flutter.
Що входить в роботу
- Infinite scroll з cursor-based чи offset пагинацією
- Захист від множественних запросів
- Footer: спіннер / помилка з retry / кінець списку
- Skeleton-завантаження для першої сторінки
- Client-side дедупліцюва при необхідності
- Кнопка «Нові елементи» для реалтайм-стрічок
Часові рамки
1–3 робочих дні залежно від складності типів елементів та вимог до обробки помилок. Вартість розраховується індивідуально.







