Бесконечная лента и обновление жестом

списки≈ 19 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Справочник упражнений кончился — начинается лента тренировок «Подхода»: история за годы, которую не отдать экрану целиком. Лента берёт её страницами: доехали до края — просим следующую. Край теперь ищет не наш расчёт из первой статьи главы, а проп onEndReached, и у него три условия и своя память, из-за которой он не срабатывает дважды на одну и ту же ленту — зато срабатывает без всякой прокрутки, когда в ленте ещё пусто. В конце — обновление потягиванием: контракт refreshing и onRefresh проверяется прямо здесь, а сам жест — только на телефоне, и это видно по исходнику, а не на слово.

Разделы темы:·

Конец списка мы уже считали руками — в статье про ScrollView: низ рамки дошёл до низа содержимого, значит, дочитали. Там же выяснилось, что считать этого мало: react-native-web досылает onScroll с тем же смещением через 100 мс тишины, и «попроси следующую страницу» срабатывало дважды. Первая задача той статьи чинила это флагом в ref, а обещание было такое: у FlatList для этого есть onEndReached, и защита у него своя. Проверим оба на одной ленте: слева тот же расчёт слово в слово, справа проп.

Лента тренировок «Подхода» — двадцать строк по 30 точек в рамке 120. Прогноз: сколько раз сработает каждый, если один раз доехать до самого конца?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Свой расчёт сработал дважды — это досыл конца, — а onEndReached один раз. Защита у него в третьем условии. Всего условий три, и все они в методе _maybeCallOnEdgeReached из VirtualizedList.js: нарисована последняя ячейка (cellsAroundViewport.last равен номеру последнего элемента), до конца осталось не больше порога, и длина содержимого не та, на которой уже звали. Последнюю список запоминает в _sentEndForContentLength и сбрасывает, когда лента уходит от края. Досыл приносит то же смещение при той же длине содержимого — и не проходит третье условие.

Теперь страницы. Источник живёт в подложке: loadPage(номер, размер) отвечает промисом, и не мгновенно. Сети в песочнице нет, да она и не нужна: в главе 6 страницы ленте будет отдавать база в самом телефоне, а чтение с диска — тоже промис. Прогноз: сколько тренировок окажется в ленте и сколько запросов уйдёт источнику, если дважды доехать до конца?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Шестьдесят тренировок за три запроса: первая страница — из эффекта при монтировании, ещё две — от двух приездов к концу. Обратите внимание, чего в примере нет: ни счёта смещений, ни scrollEventThrottle, ни флага «были ли мы уже в конце». Всё это теперь внутри списка. Зато появилось своё: страница в пути, конец истории и обновление — о них остальные разделы.

Компилятор песочницы отметит здесь onEndReached, onEndReachedThreshold и ListFooterComponent, а заодно testID и onScroll: все они приходят к FlatList из пакета @react-native/virtualized-lists, которого в наборе типов нет. В длинном сообщении про перегрузки назван будет первый такой проп по порядку записи, поэтому у разных примеров статьи он разный. А refreshing и onRefresh из следующего раздела компилятор знает: они объявлены в самом FlatList.d.ts. В проекте это всё обычные пропы, и пример выполняется здесь как есть.

Просить следующую страницу в тот миг, когда человек упёрся в последнюю строку, — поздно: он увидит пустоту и спиннер. Поэтому у списка есть onEndReachedThreshold — насколько раньше звать. Меряется он, как windowSize из прошлой статьи, в высотах рамки: 0.5 — за половину рамки до конца.

Ниже две одинаковые ленты по 40 тренировок: у первой порог не задан, у второй 0.5. Содержимое — 1200 точек, рамка 120, предел прокрутки 1080. Прогноз: сколько раз позовёт каждая, если встать за 60 точек до конца, а потом доехать до самого конца?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
За 60 точек до конца — это ровно половина рамки — вторая лента попросила страницу, первая промолчала. Первая позвала только на самом краю, и больше уже не звала: длина содержимого та же. Без пропа порог равен двум точкам, а не двум рамкам: в _maybeCallOnEdgeReached так и написано — DEFAULT_THRESHOLD_PX = 2, и рядом стоит TODO авторов с удивлением, почему здесь два пикселя, когда в остальных местах тот же пропущенный проп считается за два экрана.

Число «два экрана» не выдумано, только живёт эта двойка в другом файле: onEndReachedThresholdOrDefault из соседнего VirtualizedListProps.js возвращает 2, и список зовёт её в _adjustCellsAroundViewport, когда решает, дорисовывать ли пачку впрок. Выходит, один и тот же забытый проп значит две разные вещи: рисовать список начинает за две рамки до края, а звать вас — за две точки. Поэтому порог задают явно. Половина рамки — разумная середина: больше — лента просит страницы, до которых человек может и не долистать, меньше — он упирается в конец раньше, чем придёт ответ.

Пока страница в пути, внизу ленты показывают спиннер — это проп ListFooterComponent, подвал списка. Кажется, что подвал — украшение. Посмотрим, что он делает с краем. Ниже лента, которая просит первую страницу из эффекта и показывает подвал, пока идёт загрузка. Прогноз: сколько страниц она запросит за первую секунду — и при скольких строках в ленте?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Две страницы, и обе — пока в ленте ноль строк. Никто не прокручивал. Лента пуста, и всё её содержимое — подвал в 30 точек при рамке в 120: до конца минус 90. Отрицательное расстояние VirtualizedList обнуляет (в исходнике это ON_EDGE_REACHED_EPSILON), значит, до конца ноль — край достигнут, и раз длина содержимого изменилась, зовут снова. Первая страница ещё в пути, а вторая уже запрошена.

Отсюда правило, которое стоит запомнить раньше остальных: onEndReached зовут и без прокрутки — всегда, когда содержимое короче рамки. Короткая первая страница, пустая лента, лента из одной тренировки — все эти случаи выглядят как «человек долистал до конца». И если номер страницы растёт не в момент запроса, а в момент ответа, обе просьбы уйдут за одной и той же страницей: лента покажет одни и те же тренировки дважды, а React напечатает про повтор ключей на каждую строку — ту самую ошибку из статьи про FlatList.

Лечится флагом «страница в пути». Держать его нужно в ref, а не в состоянии: проверить флаг надо в тот же миг, когда пришёл onEndReached, а новое состояние появится только после перерисовки (часть IV, «Состояние — снимок, а не переменная»). В состоянии остаётся лишь то, что видно на экране, — сам спиннер. Прогноз: сколько запросов будет до прокрутки и сколько после одного приезда к концу?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Один запрос до прокрутки и два после: одна страница на один приезд. Флаг снимается в ответе — и в удачном, и в неудачном, иначе первая же ошибка сети заклинит ленту навсегда.

Остался конец истории. У «Подхода» её 95 тренировок, страница — 20, значит, пятая страница будет неполной, а шестая пустой. Ниже та же лента с флагом, но признак конца из ответа она не смотрит. Опыт остановит рубильник после восьмой пустой страницы — иначе он не кончился бы вовсе. Прогноз: сколько всего запросов уйдёт источнику?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Тринадцать запросов: пять за историей и восемь в пустоту, причём последние прокрутки давно кончились — лента дёргает источник сама. Круг такой: подвал появился — содержимое стало длиннее, край «новый», зовут; страница пришла пустая, подвал исчез — содержимое стало прежним, край снова «новый», зовут опять. Память _sentEndForContentLength тут не спасает, потому что длина содержимого честно меняется туда-сюда. На телефоне это не «лишний код»: это батарея, трафик и счёт за сервер, а в «Подходе» — ещё и лишние чтения базы.

Останавливает всё признак конца из ответа. Источник его и отдаёт — hasMore; у настоящего API это бывает поле next, nextCursor или пустой список, важно лишь то, что решение принимает ответ, а не список. Прогноз: сколько запросов теперь и что окажется в подвале?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Девяносто пять тренировок за пять запросов, шестого нет, и внизу человек читает «Это вся история» вместо вечного спиннера. Подвал здесь стоит всегда — так его высота не скачет, а меняется только текст: одной причиной для лишнего вызова меньше.

Лента просит страницы, которых нет, — ищите не onEndReached, а признак конца. Флаг «в пути» защищает только от второго запроса, пока идёт первый; остановить ленту у края истории может лишь ответ источника.

Второй жест любой ленты — потянуть вниз, чтобы обновить. У FlatList для него два пропа: onRefresh — что делать, и refreshing — идёт ли обновление прямо сейчас. Спиннер рисует не ваш код, а RefreshControl, который список подставляет сам. Прогноз: что будет, если потянуть ленту вниз в песочнице — сколько раз позовут onRefresh, куда уедет смещение и изменится ли число узлов в разметке?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Ноль вызовов. И потянуть не вышло: выше начала лента не уехала ни на точку — scrollTop у браузера отрицательным не бывает, а пружины, как на iOS, здесь нет. Единственный след onRefresh — лишний узел в разметке. Вот он.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Весь RefreshControl в react-native-web — это View, из которого аккуратно вынуты все десять пропов обновления. Ни жеста, ни спиннера, ни вызова onRefresh: отсюда и лишний узел — пустая обёртка вокруг рамки списка. То есть обновление потягиванием в предпросмотре не «плохо работает», его нет вовсе, и никакой прогон этого не изменит.

На телефоне за жест отвечает настоящий нативный компонент, причём на двух платформах по-разному — это видно прямо в ScrollView.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

На iOS это UIRefreshControl внутри списка (PullToRefreshViewNativeComponent), на Android — SwipeRefreshLayout, обёрнутый вокруг списка (AndroidSwipeRefreshLayoutNativeComponent). Отсюда и разные пропы: tintColor, title и titleColor — только iOS, colors, progressBackgroundColor и size — только Android. Кстати, обёртка вокруг списка — ровно то, что делает и react-native-web, только пустая: тот лишний узел из опыта выше.

И главное, что стоит понять до телефона: RefreshControl — управляемый компонент, как поле ввода из части IV. Спиннер показывает не жест, а проп refreshing.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Читается это так: палец запустил спиннер, _onRefresh позвал ваш обработчик и сразу заставил компонент обновиться. Если ваш onRefresh не поставил refreshing в true, на следующем же проходе нативному компоненту скажут «не обновляемся», и спиннер моргнёт и исчезнет, хотя запрос идёт. Ровно поэтому refreshing — обязательная половина пары, и не просто по совести: без неё экран падает.

Проверим обязательность прямо здесь — это единственная часть жеста, которая в песочнице настоящая. Исключение изнутри подключённой библиотеки браузер прячет, поэтому пример обёрнут в границу ошибок из части IV. Прогноз: что окажется на экране?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Вместо ленты — текст исключения. Проверка стоит в _defaultRenderScrollComponent у VirtualizedList, одинаково в react-native-web и в React Native 0.86: onRefresh есть, а refreshing не логическое значение — Invariant Violation, и экран падает целиком. <i> — это имя списка в сжатой сборке песочницы; на телефоне там стояло бы VirtualizedList.

Значит, в песочнице проверяемо всё, кроме самого жеста: пара пропов, обработчик и то, что он делает с лентой. Повесим тот же обработчик на кнопку — на телефоне он же уедет в onRefresh, ни строчкой не изменившись. Обновление ленты — это не «дозагрузить», а «собрать заново с самого верха»: страницы сбрасываются, курсор тоже. В зале записали тренировку — прогноз: что окажется сверху ленты до и после обновления?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Новая тренировка встала первой, а страницы начались заново — и курсор вместе с ними. Заметьте, где он меняется: не в тот миг, когда нажали «Обновить», а когда пришёл ответ, вместе с данными. Что бывает, если сбрасывать его раньше, — вторая задача.

А сам жест проверяют на устройстве — иначе никак. В симуляторе iOS ленту тянут мышью от верхнего края вниз, на Android-эмуляторе так же; на телефоне через Expo Go или свой dev client (глава 10) — пальцем. Смотреть надо на три вещи: спиннер появился, спиннер исчез (то есть refreshing вернулся в false и в удачном, и в неудачном случае), а повторный жест во время обновления не запустил второй запрос. Первое и третье в песочнице не проверить вовсе, а второе — это ваш обработчик, и он проверяется примером выше.

Сведём в одно место, чем бесконечная лента в песочнице отличается от ленты на телефоне. Каждая строка сверена с исходниками, левая колонка — ещё и запуском выше.

ЧтоВ песочнице (react-native-web 0.21)На телефоне (React Native 0.86)
Жест «потянуть вниз»нет вовсе: RefreshControl — это View без всех десяти пропов обновления, onRefresh не зовут никогда, в разметке остаётся один лишний узелiOS — UIRefreshControl внутри списка, Android — SwipeRefreshLayout вокруг списка; жест, спиннер и вызов onRefresh настоящие
Пропы RefreshControlотбрасываются все: tintColor, title, colors, progressViewOffset, sizetintColor, title, titleColor — только iOS; colors, progressBackgroundColor, size — только Android
onRefresh без refreshingInvariant Violation «`refreshing` prop must be set as a boolean…», экран падаетта же проверка с тем же текстом в _defaultRenderScrollComponent
Смещение выше началане бывает: scrollTop браузера не уходит в минус, тянуть некудана iOS лента пружинит (bounces), и жест обновления живёт именно в этом минусе
onEndReached без onEndReachedThresholdпорог 2 точки (DEFAULT_THRESHOLD_PX), хотя дорисовка запаса считает тот же пропущенный проп за 2 рамкито же самое: константа и onEndReachedThresholdOrDefault совпадают
Повтор вызовапо разу на длину содержимого; досыл onScroll через 100 мс тишины второй раз не зовётто же правило по _sentEndForContentLength; досыла нет вовсе
Проверка края после дорисовкитолько из _onLayout, _onContentSizeChange и _onScrollплюс из componentDidUpdate, когда задан getItemLayout: с ним можно улететь за последнюю ячейку, не вызвав ни раскладки, ни события
Готовность метрикпроверки нет: край считают от того, что естьесть: без длины содержимого и при незавершённой программной прокрутке (pendingScrollUpdateCount) край не проверяют
ТипыonEndReached, onEndReachedThreshold, ListFooterComponent, а также testID и onScroll — ошибка компилятора; refreshing и onRefresh он знаетвсё это обычные пропы из @react-native/virtualized-lists

Правило то же, что во всей главе: код пишется так, чтобы быть верным в обеих колонках. Порог — явный. Страница в пути — флаг в ref. Конец истории — из ответа источника. refreshing — всегда рядом с onRefresh и всегда возвращается в false. А сам жест — на устройстве, вместе с пружиной, спиннером и вторым пальцем.

На этом глава про списки закончена: справочник листается окном, секции липнут шапками, лента берёт историю страницами. Дальше — глава про ввод: тренировку в зале вводят руками, часто одной рукой, и там выяснится, что нажатие в React Native — не onClick, а автомат из пяти состояний.

  • onEndReached зовут, когда сошлись три условия: нарисована последняя ячейка, до конца не больше порога и длина содержимого не та, на которой уже звали. Третье — готовая защита от повтора, ради которой в статье про ScrollView заводили флаг вручную.
  • onEndReachedThreshold меряется в высотах рамки; без него порог — две точки, хотя тот же пропущенный проп для дорисовки впрок считается за две рамки. Поэтому его задают явно, обычно около половины рамки.
  • Содержимое короче рамки — край достигнут, и onEndReached зовут без всякой прокрутки: пустая лента, короткая первая страница, подвал-спиннер. Отсюда флаг «страница в пути», и держат его в ref, а не в состоянии.
  • Конец истории останавливает только признак из ответа источника. Без него подвал появляется и исчезает, длина содержимого скачет, и лента бесконечно просит страницы, которых нет: 13 запросов вместо 5 в опыте выше.
  • Страницы берут по курсору, а не по номеру: пока ленту листают, сверху появляются новые тренировки, и сдвиг начинает повторять уже показанные строки. У обновления и подгрузки один источник — значит, и одна занятость, а курсор меняется вместе с данными.
  • Обновление потягиванием — пара refreshing и onRefresh, управляемая, как поле ввода. В песочнице проверяется только контракт: RefreshControl здесь — пустой View, жеста нет. Сам жест, спиннер и повторное потягивание смотрят на устройстве.

1/4У FlatList задан onEndReached, а onEndReachedThreshold — нет. Когда список позовёт обработчик?

2/4Лента пуста, первая страница ещё в пути, внизу подвал «Загружаем…» высотой 30 точек, рамка — 120. Что сделает список?

3/4История кончилась, источник отдаёт пустые страницы, признак конца лента не смотрит. Что будет?

4/4В предпросмотре тянут ленту вниз, у которой заданы refreshing и onRefresh. Что произойдёт?

Страницы по номеру и новая тренировка

Лента просит страницы по номеру — «дай двадцать, пропустив первые двадцать», как делают очень многие API. Человек долистал до конца, записал в зале тренировку, и она встала первой в истории. Нажмите «Записать тренировку», потом «Ещё страницу» — и React начнёт ругаться на повтор ключей. Почините, не трогая keyExtractor: id у тренировок уникальные, и дело не в них.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Номер страницы — это «сколько пропустить сверху», а сверху добавилась тренировка. Первая страница показала w-95…w-76, потом история сдвинулась на одну, и вторая страница, пропустив двадцать самых новых, начала с w-76 — той, что уже на экране. Так работает любая нумерация по сдвигу: пока лента листается, начало съезжает.

Лечит курсор — «дай двадцать старше вот этой». Источник умеет и так, это loadBefore, и он уже объявлен в примере:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь в консоли одна строка: вторая страница начинается со следующей за последней показанной, что бы ни случилось наверху. Курсором бывает id последней строки, дата или номер от сервера — важно, что он указывает на место в истории, а не на количество пропущенных. В главе 7 курсор станет номером операции в журнале сервера, и по той же причине: время и сдвиг врут, а место — нет.

Обновление и подгрузка наперегонки

Лента на курсоре, обновление сбрасывает курсор в null, чтобы начать сверху. Человек потянул ленту и тут же, не дожидаясь ответа, доехал до конца — и подгрузка ушла за той же самой первой страницей. Почините так, чтобы обе кнопки можно было жать в любом порядке и как угодно быстро. Страница здесь — четыре тренировки, чтобы дубли было видно по именам.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Курсор сбросили в null сразу, а данные пришли потом — и в эту щель попала подгрузка: loadBefore(null, 4) — это опять самая первая страница, и она дописалась к уже обновлённой ленте. Отсюда две правки, и обе про одно: у ленты одна занятость, а курсор меняется вместе с данными, а не раньше.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Остаётся одна строка вывода. Тот же самый флаг, что защищал от второго запроса в разделе про подвал, — он же и разводит обновление с подгрузкой: обе дороги ведут к одному источнику, значит, и занятость у них общая. Если в приложении нажатие «Обновить» должно прерывать подгрузку, а не пережидать её, ответ старого запроса придётся не ждать, а выбрасывать — это тот же приём, что флаг ignore из статьи «Уборка, двойной запуск и гонки запросов» части IV: ответ, который уже не нужен, приходит, но никуда не попадает.