Собираем окно: список, который рисует только видимое
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Прошлая статья кончилась счётом: тысяча упражнений в ScrollView — это тысяча вызовов строки и 2001 узел в разметке, хотя видно четыре строки. Там же был назван выход: высота строки известна, высоту рамки приносит onLayout, смещение — onScroll, значит, видимые строки находятся делением. Соберём такое окно своими руками: функцию диапазона, распорки, запас. И посмотрим, чем окно платит за скорость — пустотой при быстром броске и состоянием строк, уехавших за край. Всё это нижний этаж, сам React Native, и всё выполняется в песочнице. FlatList из следующей статьи окажется той же миниатюрой, только с ручками.
ScrollView рисует всё, что ему дали, — значит, давать ему нужно меньше: только строки, которые задевает рамка. Для этого нужны три числа, и все три у нас уже есть. Высоту строки задаём сами, 30 точек. Высота рамки приходит в onLayout, у нас она 120. Смещение — contentOffset.y из onScroll.
Первая видимая строка — та, в которую попал верх рамки: Math.floor(y / 30). Последняя — та, в которую попал низ, то есть y + 120. Здесь одна тонкость: если низ рамки пришёлся ровно на границу строк, следующая строка ещё не видна. И края: номер не должен уйти меньше нуля или дальше последней строки. Функция не знает ни про React, ни про экран — это чистая арифметика, поэтому пример ниже на TypeScript, без экрана.
Прогноз: сколько строк задевает рамка при каждом из пяти смещений? Особенно интересны 45 и −40.
При смещении 0 и 600 строк четыре — ровно столько влезает в рамку. При 45 — пять: рамка сдвинута на полторы строки, сверху видна нижняя половина строки 1, снизу — верхняя половина строки 5. Отсюда размер окна: не 120 / 30, а на одну строку больше. В конце списка отсечка по total - 1 не даёт выйти за строку 999.
Отрицательное смещение — не выдумка. На iOS список по умолчанию пружинит: потяните его вниз от начала, и contentOffset.y уйдёт в минус, пока идёт отскок. Без Math.max(0, y) номер первой строки стал бы −2, а slice считает отрицательное начало от конца справочника: slice(-2, 3) — пустой массив, окно без единой строки. У отскока в конце списка смещение, наоборот, больше предельного, и его режет тот же Math.min.
Номер первой строки меняется раз в 30 точек, а смещение — каждый кадр. В состояние поэтому кладут номер, а не смещение: это правило порога из прошлой статьи.
Нарисовать только окно — половина дела. Если положить в ScrollView пять строк, содержимое будет высотой в пять строк, и прокручивать станет некуда. Строки, которых нет, должны занимать своё место. Для этого сверху ставится пустой View высотой first × 30, снизу — такой же, высотой во все строки после окна. Это распорки. Номер первой строки лежит в состоянии — обычный useState, как в части IV.
Функция visibleRange из прошлого раздела с этого места живёт в подложке и объявляется в примерах через declare, как makeCatalog. Прогноз: какие окна напечатаются, какой высоты окажется содержимое и сколько будет вызовов Row и узлов после прокрутки к 600?
Row десять: пять при монтировании и пять после сдвига. Строки ex-21…ex-25 пришли новыми, а первые пять ушли из дерева. Узлов 13 вместо 2001: пять строк по два узла, две распорки и внутренняя коробка ScrollView.В подложке с этой статьи появилась строка CSS overflow-anchor: none, и она не про React Native, а про браузер. У браузера есть привязка прокрутки: если над видимыми строками что-то выросло, он сам сдвигает прокрутку, чтобы строки не прыгали. Окно с распорками такой помощи не переживает. Браузер сдвигает прокрутку, окно пересчитывается от нового смещения, верхняя распорка снова растёт — и так по кругу. Без этой строки список в третьей задаче уезжает сам, раз за разом, по две строки за шаг. ScrollView на телефоне так сам не делает — похожая привязка там включается только явно, пропом maintainVisibleContentPosition, — поэтому в песочнице её выключили: пусть пример ведёт себя как на устройстве.
Уберём обе распорки и оставим одно окно. Прогноз: какой высоты будет содержимое и до какого смещения доедет прокрутка, если попросить 600?
Содержимое — 150, пять строк, и прокрутка упирается в 30: дальше показывать нечего. Просьбу о 600 браузер урезал до предела, палец на телефоне так же упрётся в конец. До строки 20 теперь не добраться никак.
И даже эти 30 точек показывают не то. При смещении 30 окно сдвинулось на строку 1, строка 0 исчезла сверху, и всё содержимое съехало на строку вверх. Окно считает видимыми строки 1…4, а рамка показывает 2…5. Верхняя распорка держит каждую строку на её законном месте: строка i всегда начинается на i × 30, сколько бы строк над ней ни убрали.
Окно в пять строк верно ровно в тот миг, когда пришёл onScroll. Но прокрутка React не ждёт. На телефоне содержимое едет в нативном потоке прямо под пальцем, JS узнаёт об этом событием чуть позже и ещё позже перерисовывает. В песочнице так же: браузер прокручивает узел сразу, а onScroll приходит асинхронно, как мы видели в прошлой статье. Всё это время на экране то, что нарисовано под старое окно, а при новом смещении там может оказаться пусто.
Лечится запасом: рисуют по несколько строк сверх видимых с каждой стороны. Ниже два списка рядом — без запаса и с запасом в четыре строки. Помощник подложки blankOnScreen меряет, сколько точек рамки ничем не закрыто; как и countNodes, в разметку за пример заглядывает подложка. Мерим сразу после прокрутки, пока onScroll не пришёл, и ещё раз после него. Прогноз: четыре пары чисел.
Сдвиг на 90 — это три строки. Без запаса нарисованы строки 0…4, то есть точки 0…150, а рамка теперь стоит на 90…210: 60 точек пусто, две строки белого. С запасом нарисовано до строки 8, и пустоты нет. После onScroll оба окна догоняют смещение. Прыжок на 900 больше любого запаса: пусто в обоих списках, пока не придёт событие. Запас спасает от частых маленьких сдвигов, а не от броска.
FlatList держит запас не в строках, а в высотах рамки. Его проп windowSize по умолчанию равен 21: видимый экран и до десяти экранов сверху и снизу — так написано в комментарии к windowSize в VirtualizedListProps.js — это файл пакета @react-native/virtualized-lists, куда React Native 0.86 вынес окно списка со всеми его описаниями. Наши четыре строки с каждой стороны — ровно по экрану, то есть windowSize={3}. Цена запаса — те самые строки, за которые платил наивный список: чем больше запас, тем меньше пустоты и тем больше работы.
Пустота при быстром броске — не ошибка вашего окна. В шапке VirtualizedList.js это названо прямо: список можно прокрутить быстрее, чем он дорисовывает. Лечат это размером запаса и лёгкой строкой, а не отказом от окна.
Окно не только рисует строки — оно их размонтирует. Строка, уехавшая за край окна, уходит из дерева целиком, вместе со своим состоянием; что бывает с состоянием убранного компонента, разбирала статья «Когда состояние сохраняется, а когда сбрасывается» в части IV. Новое здесь одно: убирает строку не ваш код, а прокрутка, и молча.
Отметка «сделано» у строки — её локальное состояние. Отмечаем первое упражнение, уезжаем к 600 и возвращаемся. Прогноз: что напечатается третьей строкой?
Отметка пропала. Вернувшаяся строка — новый компонент с новым useState(false), а старый вместе с отметкой исчез, как только строка ушла за окно. У FlatList то же самое, и это записано прямо в исходнике: первая же оговорка в шапке VirtualizedList.js React Native 0.86 — внутреннее состояние не сохраняется, когда содержимое уходит за окно отрисовки. Данные там советуют держать в самом элементе списка или во внешнем хранилище. Как это сделать здесь, — первая задача.
Ключи в окне тоже требовательнее, чем в обычном списке. key={exercise.id} привязывает строку к упражнению. Ключ по месту в окне — индекс внутри slice — в обычном списке ломается при вставке и удалении (статья «Своя сверка: что на самом деле делает key»). В окне он ломается на каждой прокрутке: места в окне одни и те же, а упражнения под ними меняются каждые 30 точек. Это третья задача.
Соберём всё в один компонент: данные, высота рамки и строки, запас и функция, которая рисует строку. Ровно такой набор ручек будет у FlatList в следующей статье. Прогноз: сколько раз вызовется renderRow и сколько узлов окажется в разметке после прокрутки к 600?
Узлов 29. Окно при смещении 600 — строки 16…28, тринадцать штук: та самая дюжина вместо тысячи. Каждая строка — два узла, плюс две распорки и внутренняя коробка.
А вызовов 35, а не 22. Девять — при монтировании (строки 0…8), тринадцать — после прокрутки, и ещё тринадцать сверху. Лишний проход — досыл конца из прошлой статьи: react-native-web повторяет onScroll с тем же 600, и setFirst(20) кладёт в состояние то же число. Перерисовки не будет, но React не всегда видит это заранее: иногда он сначала вызывает сам компонент и лишь потом, сравнив состояние через Object.is, пропускает его детей. Такую оговорку делает и описание useState на react.dev. Строки-компоненты в таком проходе не вызываются — поэтому в разделе про распорки вызовов Row было ровно десять. А renderRow вызывается прямо в теле списка, и её проход не пропустить.
Поэтому FlatList вызывает renderItem не в своём теле, а внутри отдельного компонента ячейки — CellRenderer из VirtualizedListCellRenderer.js. В React Native 0.86 это PureComponent: с теми же пропсами ячейка не перерисовывается. В копии списка внутри react-native-web ячейка — обычный Component, так что в песочнице этой защиты нет; чем это оборачивается, покажут две следующие статьи. Остальное соответствует так:
| Своё окно | FlatList (React Native 0.86) |
|---|---|
| высота строки и деление | getItemLayout(data, index) → { length, offset, index }; без него строки меряются через onLayout, а для ещё не измеренных берётся средняя высота (ListMetricsAggregator.js) |
запас overscan в строках | windowSize в высотах рамки, по умолчанию 21 |
| первое окно по высоте рамки | initialNumToRender, по умолчанию 10 строк; дальше дорисовка пачками по maxToRenderPerBatch, тоже 10 |
распорки — пустые View | пустые View с ключом $spacer-… и высотой из метрик строк |
key={item.id} | keyExtractor, а без него — поле key или id элемента |
renderRow в теле списка | renderItem внутри ячейки CellRenderer; на телефоне она PureComponent |
| состояние строки пропадает за окном | то же самое — первая оговорка в шапке VirtualizedList.js |
Всё это — нижний этаж, сам React Native: FlatList — тонкая обёртка из самого пакета react-native, а окно у неё — VirtualizedList из пакета @react-native/virtualized-lists; в песочнице работают их копии внутри react-native-web. Чего у нашего окна нет вовсе: строк разной высоты без подсказки, дорисовки запаса пачками, onEndReached. Следующая статья переведёт справочник на FlatList — и найдёт в нём всё, что собрано здесь.
Арифметика окна одинакова везде: деление, распорки и запас не знают, где выполняются. Расходится то, что происходит вокруг них, — прокрутка, события и то, что браузер делает сам. Сведём это в одно место; левая колонка проверена запусками выше, правая — по исходникам React Native 0.86.
| Что | В песочнице (react-native-web 0.21) | На телефоне (React Native 0.86) |
|---|---|---|
| Когда JS узнаёт о прокрутке | браузер сдвигает узел сразу, onScroll приходит асинхронно — отсюда пустота, которую мерили в разделе про запас | содержимое едет в нативном потоке под пальцем, событие доходит до JS позже; пустота при броске та же, о ней предупреждает шапка VirtualizedList.js |
| Конец прокрутки | повтор onScroll с тем же смещением через 100 мс тишины — и лишний проход тела списка | повтора нет; конец жеста приходит отдельными событиями onScrollEndDrag и onMomentumScrollEnd |
| Над видимыми строками выросла распорка | браузерная привязка прокрутки сдвинула бы смещение, и окно поехало бы само; в подложке она выключена строкой overflow-anchor: none | смещение остаётся прежним, пока не задан проп maintainVisibleContentPosition |
| Смещение за краями | scrollTop браузера не выходит за края: просьба о −40 даёт 0, о 10000 — предел прокрутки | на iOS список по умолчанию пружинит (bounces), и во время отскока y выходит за края — ради этого в visibleRange стоят Math.max и Math.min |
Ячейка FlatList | CellRenderer — React.Component: перерисовывается с каждым проходом списка | CellRenderer — React.PureComponent: с теми же пропсами не перерисовывается |
Правило то же, что во всей главе: код окна пишется так, чтобы быть верным в обеих колонках. Смещение режется с обеих сторон, даже если в предпросмотре оно за края не выходит. Обработчик onScroll спокойно переживает повтор. В состоянии лежит номер строки, а не смещение.
- Окно — это деление: первая строка
Math.floor(y / h), последняяMath.ceil((y + H) / h) - 1, с отсечкой по краям. Рамка, сдвинутая не на целое число строк, задевает на одну строку больше, чем в неё влезает. - Распорки — пустые
Viewсверху и снизу — держат полную высоту содержимого и ставят каждую строку на её место. Без них прокрутка упирается в нарисованное. - В состоянии лежит номер первой строки, а не смещение: перерисовка раз в строку, а не каждый кадр. Итог — дюжина строк и 29 узлов вместо тысячи строк и 2001 узла.
- Запас с обеих сторон закрывает пустоту, пока
onScrollв пути. Бросок дальше запаса всё равно покажет пустоту — уFlatListтоже; там запас задаётwindowSize, по умолчанию 21 экран. - Строка за окном размонтируется вместе со своим состоянием. Ключ — упражнение, а не место в окне; состояние, которое должно пережить прокрутку, держат выше строки.
FlatList— та же миниатюра:getItemLayoutвместо деления,windowSizeвместо запаса, распорки$spacer-…,keyExtractorвместоkeyиrenderItemвнутри ячейкиCellRenderer, которая на телефоне с теми же пропсами не перерисовывается.
1/4Рамка 120 точек, строка 30, смещение 45. Сколько строк задевает рамка?
2/4Окно рисует пять строк, а распорок нет. Что увидит пользователь справочника на тысячу упражнений?
3/4В нашем окне у строки своё состояние «отмечено». Строку отметили, прокрутили на тысячу строк вниз и вернулись. Что с отметкой?
4/4Что значит windowSize={21} у FlatList?
Отметка переживает прокрутку
Отметка из раздела «Строка за краем окна» пропадает, стоит строке уехать. Сделайте так, чтобы после возвращения к началу она осталась на месте — не отказываясь от окна.
Строка живёт, пока она в окне, а список — пока жив экран. Значит, отметки переезжают из строки в список, и строка получает готовое done и функцию переключения:
Теперь третья строка — «отмечено»: вернувшаяся строка снова новый компонент, но спрашивает она список, а список всё помнит. Множество заменяется новым, а не правится на месте, — иначе Object.is не увидел бы изменения (как в части IV). В «Подходе» отметки потом переедут ещё выше, в хранилище главы 6. У FlatList есть подвох, которого нет у нашего окна: список — PureComponent, и если строки зависят от чего-то кроме data, это передают пропом extraData. Подробно — в следующей статье.
Строки разной высоты
В справочнике с заголовками групп первая строка группы вдвое выше: 60 вместо 30. Деление на 30 врёт с первой же строки. Перепишите rangeByOffsets так, чтобы она искала строки по массиву offsets — верхам всех строк, — и не перебирала его целиком.
Верхи строк растут, поэтому строку, в которую попала точка, находит двоичный поиск: двадцать сравнений на миллион строк вместо миллиона.
Выходит 0 … 2, 15 … 17 и 30 … 34. При смещении 1000 строка 30 начинается на 960 и высотой 60, а строка 34 — на 1110, и низ рамки на 1120 её задевает. Пара «верх строки и её высота» — ровно то, что возвращает getItemLayout у FlatList: { offset, length, index }. С ним список ищет строки так же, по готовым числам. Без него он строку сначала рисует, меряет через onLayout и до замера гадает по средней высоте. Как это сказывается на скорости, — через статью.
Отметка переезжает
Здесь ключ строки — её место в окне. Отметьте второе упражнение и прокрутите список на две строки. Предскажите вывод, объясните его и почините одной правкой.
Вторая строка вывода называет упражнение, которое никто не отмечал. Ключи в окне — места 0…4, и после прокрутки они те же. React видит в месте 1 тот же компонент, оставляет ему состояние done = true и лишь подаёт новые пропсы: вместо «Жим гантелей лёжа» там теперь «Жим гантелей на наклонной скамье». Эффект видит новое имя и печатает. Отметка приклеена к месту на экране, а не к упражнению.
С ключом по упражнению вывод — одна строка. После прокрутки «Жим гантелей лёжа» уходит из окна и размонтируется вместе с отметкой — это уже первая задача, и лечится она так же. FlatList ключ берёт сам: поле key или id элемента — так сказано в шапке VirtualizedList.js, — а если их нет, номер строки. На этот случай в исходнике VirtualizedList лежит предупреждение «VirtualizedList: missing keys for items…», и по нему выходит, что о номерах вместо ключей FlatList предупредит. Сбудется ли это обещание, проверит запуском следующая статья.