ScrollView и событие прокрутки

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

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

Справочник «Подхода» — сто двадцать упражнений, а на экран телефона влезает строк десять. В браузере лишнее уезжало вниз, и прокручивалась страница; в React Native страницы нет, и прокручивается только то, что положено в ScrollView. Разберём, из каких двух коробок он состоит, что вместо scrollTop приносит событие прокрутки — и во что обходится самый прямой способ показать список, map внутри ScrollView, когда упражнений тысяча, а видно четыре. ScrollView живёт на нижнем этаже, в самом React Native, поэтому всё в статье выполняется в песочнице.

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

В браузере об этом думать не приходилось: что не влезло, уезжает вниз, и страницу прокручивает сам браузер. В React Native страницы нет. Корневой View размером ровно с экран, и всё, что в него не поместилось, уходит за край — оттуда пальцем не достать. Прокручивается только то, что положено в ScrollView. Это такой же примитив нижнего этажа, как View и Text, и в песочнице он работает через react-native-web.

Внутри у ScrollView две коробки. Внешняя — рамка, окно на экране; её оформляет обычный style. Внутренняя — содержимое, коробка высотой со все строки вместе; её оформляет отдельный проп contentContainerStyle. Прокрутка — это сдвиг содержимого внутри рамки. Размеры обеих коробок узнаются без всякого DOM: у каждого примитива React Native есть событие onLayout с размером после раскладки, а у ScrollView ещё и onContentSizeChange — размер внутренней коробки.

Тридцать упражнений по 30 точек в высоту, рамке задано 120. Прогноз: какие два числа напечатаются?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
Содержимое — 900: тридцать строк по тридцать. Рамка — 120, ровно сколько задано, и между ними 780 точек, которые можно прокрутить. Единицы — те самые независимые от плотности точки из прошлой главы; в песочнице точка равна пикселю CSS. Порядок двух строк — деталь песочницы: onLayout здесь сделан на ResizeObserver, и полагаться на то, кто придёт первым, не стоит.

Высоту рамке пришлось задать числом. Что будет, если её не задавать, а положить ScrollView в обёртку, у которой высоты тоже нет? Прогноз: сколько останется прокручивать?

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

Нисколько: рамка выросла до содержимого, 900 и 900. ScrollView тут ни при чём. По умолчанию у него flexGrow: 1 и flexShrink: 1 — эти две записи стоят в стиле baseVertical и у React Native 0.86, и у react-native-web, — то есть рамка берёт ту высоту, которую отводит родитель. Родитель без высоты отводит «сколько попросишь», и рамка просит всё. На телефоне корень приложения — экран, поэтому ScrollView прямо в корне прокручивается; ломается он, когда между ними встаёт обёртка без flex: 1. У корня песочницы высоты нет вовсе, поэтому в примерах главы рамке задана высота числом.

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

Список не прокручивается — первым делом onLayout рамки. Рамка равна содержимому: виноват не ScrollView, а родитель без высоты.

Вторая коробка отвечает за всё, что касается строк: отступы вокруг списка, выравнивание, промежутки. Пишут это в contentContainerStyle, а не в style, — и для alignItems с justifyContent это не совет, а проверка. Она стоит и в React Native 0.86, и в react-native-web, с одним и тем же текстом, но только в сборке для разработки: __DEV__ в одном исходнике, NODE_ENV в другом. В боевой сборке ошибку просто никто не заметит.

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

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

Строка до экрана не доберётся: вместо списка на экране текст исключения, а в консоли — отладочное сообщение React с ним же. Invariant Violation — не предупреждение, экран с таким ScrollView в разработке падает целиком. <oS> — так в сжатой сборке песочницы называется ScrollView; на телефоне там стояло бы настоящее имя. Перенесите alignItems в contentContainerStyle — и строка встанет посередине.

В браузере положение прокрутки читали у элемента, element.scrollTop. Здесь элемента нет, и положение приносит событие. Обработчик onScroll получает e.nativeEvent, а в нём три размера: contentOffset — насколько сдвинуто содержимое, layoutMeasurement — размер рамки, contentSize — размер содержимого. Те же две коробки плюс сдвиг между ними. Отсюда главный расчёт любого списка: низ рамки дошёл до низа содержимого — дочитали до конца.

fireEvent.scroll в песочнице прокручивает по-настоящему, и событие приходит асинхронно, как от пальца. Ниже три прокрутки подряд с интервалом в пару кадров. Прогноз: сколько строк y = будет в выводе?

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

Строк y две, а не три: 300 и 780, середина пропала. И перед каждой — длинная строка, которой в коде нет. Это настоящее отладочное сообщение react-native-web: его печатает _handleScroll в ScrollView/index.js, если onScroll задан, а scrollEventThrottle — нет. Куда пропала середина, видно в соседнем ScrollViewBase.js: очередное событие отдаётся, только если scrollEventThrottle больше нуля и с прошлого прошло не меньше этих миллисекунд. Без пропа там ноль — значит, только начало прокрутки и её конец, который react-native-web досылает сам после 100 мс тишины. Сообщение обещает одно событие, а их два: конец оно не считает.

Лечится одним пропом. Прогноз: сколько строк y теперь?

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

Пришли все три прокрутки — и четвёртая строка сверху: 780 дважды. Это тот самый досыл конца: через 100 мс без движения react-native-web повторяет onScroll с последним положением. Отсюда правило на всю главу: обработчик onScroll обязан спокойно переживать одно и то же смещение дважды. «Дошёл до конца — загрузи ещё» без защиты попросит одну страницу два раза; как защититься, — первая задача.

scrollEventThrottle={16} пишут всегда, даже когда кажется, что событий и так хватает, — и дело не в песочнице. На телефоне пропуск пропа значит противоположное: React Native 0.86 без него не прореживает события вовсе. В RCTScrollViewComponentView.mm всё, что не больше 1/60 секунды, считается нулём, в ReactScrollViewHelper.kt прореживание включается только от 17 мс. Одно и то же приложение без пропа получает в песочнице два события, а на телефоне — каждое. С 16 поведение одинаковое везде.

На экране 120 Гц кадр вдвое короче 16 мс, но на телефоне 16 всё равно значит «без пауз»: React Native на обеих платформах считает нулём всё, что не длиннее кадра при 60 Гц.

Ещё одно отличие песочницы видно, если сохранить само событие, а не числа из него. Прогноз: одинаковыми ли выйдут первые два массива?

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

Разными. В react-native-web nativeEvent — объект с геттерами: contentOffset.y при каждом чтении заново берёт scrollTop у узла (функция normalizeScrollEvent в ScrollViewBase.js). Сохранённое событие показывает не момент события, а «сейчас». На телефоне nativeEvent — обычный объект с числами, и такой ловушки нет; но код, который копирует числа сразу, верен на обеих платформах, а код, который хранит событие, — только на одной.

Третья строка — о другом. По типу NativeScrollEvent поля contentInset и zoomScale обязательны, а в песочнице их нет: react-native-web собирает только три размера. Типы React Native и здесь шире рантайма предпросмотра.

Прокрутка из кода — тоже через ref, как в части IV, только в нём не DOM-узел, а сам ScrollView с методами scrollTo({ x, y, animated }) и scrollToEnd({ animated }). Тип такой ссылки — React.ComponentRef<typeof ScrollView>. Классическое применение — кнопка «наверх», которая появляется, когда список ушёл дальше экрана.

Прокрутка в примере без анимации ради предсказуемости: с animated: true песочница прислала бы вереницу промежуточных положений. Прогноз: в каком порядке пойдут строки y и строки про кнопку?

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

Каждое положение пришло дважды — досыл конца из прошлого раздела. А кнопка сменила вид ровно два раза, хотя setShowTop вызывался четыре: scrollTo тоже порождает onScroll, как палец, и прокрутка к нулю спрятала кнопку тем же путём, каким её показала.

Главное здесь — что лежит в состоянии. Не смещение, а порог y > 300. Смещение меняется на каждом событии, порог — дважды за прокрутку, а setState с тем же значением перерисовки не вызывает (Object.is, как в части IV). Положи мы в состояние сам y — каждое событие, то есть каждый кадр, перерисовывало бы экран вместе со всем списком. Сколько это стоит на тысяче строк, — вторая задача.

Тип обещает экземпляр ScrollView. Посмотрим, что в рантайме, — и заодно попросим у него то, чего у ScrollView быть не должно.

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

<div> — узел разметки. react-native-web приклеивает к нему scrollTo, scrollToEnd и ещё несколько методов (_setScrollNodeRef в ScrollView/index.js, с честной припиской, что это временно). Поэтому в песочнице scrollTop на месте и печатает ноль — а компилятор его отвергает: «Свойство "scrollTop" не существует в типе "ScrollView"». Прав компилятор. На телефоне в ref лежит нативный компонент, свойств DOM у него нет, и код, который на них опирается, работает только в предпросмотре.

Теперь весь справочник. Самый прямой способ показать список — map внутри ScrollView с key у каждой строки (зачем — как в части IV; у FlatList через две статьи для этого будет keyExtractor). Тысяча строк — это те же 120 упражнений с вариантами хвата и снаряда: помощник makeCatalog из подложки. Считать будем двумя счётчиками: сколько раз вызван компонент строки и сколько узлов оказалось в разметке. Второе — единственное место главы, где нужен взгляд в разметку, и делает его помощник подложки countNodes: сам пример про document не знает, на телефоне его нет.

Прогноз: на экране четыре строки. Сколько вызовов Row и сколько узлов?

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

Тысяча вызовов и 2001 узел: по View и Text на строку плюс внутренняя коробка ScrollView. ScrollView не знает, что видно, а что нет: он создаёт всё, что ему дали, и держит это, пока экран жив. Из тысячи строк работают четыре, а 996 лежат за краем рамки и платят полную цену — память, раскладку и время первой отрисовки.

Время можно измерить компонентом React.Profiler — программной версией вкладки Profiler из статьи «Триггер, рендер, коммит». В песочнице первая отрисовка тридцати строк укладывается в единицы миллисекунд, а тысячи — в десятки: дольше двух кадров по 16,7 мс, в которые экран стоит. Точные числа зависят от машины, а отладочная сборка React медленнее боевой, поэтому мерьте у себя; держится не число, а пропорция: время растёт с числом строк, а не с числом видимых.

И это только монтирование. Любая перерисовка экрана со списком — новый символ в поиске, отмеченное упражнение, смещение, неосторожно положенное в состояние, — снова вызывает Row тысячу раз.

На телефоне новая архитектура «сплющивает» View, которые нужны только для раскладки, — нативных вьюх там может оказаться меньше двух тысяч. Но вызовов Row и элементов React будет ровно столько же: тысяча.

Выход виден из того, что уже собрано в этой статье. Высота строки известна — 30. Высота рамки приходит в onLayout, смещение — в onScroll. Значит, видимый диапазон — простое деление, и рисовать можно четыре строки с небольшим запасом, а не тысячу. Следующая статья соберёт такое окно своими руками, а FlatList после неё окажется той же идеей, доведённой до конца.

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

ЧтоВ песочнице (react-native-web 0.21)На телефоне (React Native 0.86)
onScroll без scrollEventThrottleтолько начало и конец прокрутки плюс отладочная строка «You specified `onScroll`…»каждое событие: iOS считает всё до 1/60 с нулём, Android прореживает только от 17 мс; отладочной строки нет
Конец прокруткиповтор onScroll с последним положением через 100 мс тишиныповтора нет; конец жеста — отдельные события onScrollEndDrag и onMomentumScrollEnd
e.nativeEventтри поля-геттера, читают прокрутку в момент обращения; contentInset и zoomScale — undefinedобычный объект с числами, NativeScrollEvent целиком
ref.currentdiv с приклеенными scrollTo и scrollToEndнативный компонент с теми же методами и без свойств DOM
Высота рамкиflexGrow: 1, flexShrink: 1 — берёт у родителято же самое: те же две записи в baseVertical
alignItems в styleInvariant Violation в разработкета же проверка с тем же текстом, тоже только под __DEV__

Правило из таблицы одно: код прокрутки пишется так, чтобы быть верным в обеих колонках сразу. scrollEventThrottle={16} — всегда; числа из события — копировать сразу; обработчик — готов к повтору; к ref — только через методы ScrollView, а не через то, что случайно нашлось у div.

  • В React Native страница сама не прокручивается: прокручивается только ScrollView. У него две коробки — рамка (style, размер в onLayout) и содержимое (contentContainerStyle, размер в onContentSizeChange). Выравнивание строк — только в contentContainerStyle, иначе Invariant Violation.
  • Рамка берёт высоту у родителя (flexGrow: 1, flexShrink: 1). Родитель без высоты — рамка равна содержимому, и прокручивать нечего.
  • Вместо scrollTop — e.nativeEvent: contentOffset, layoutMeasurement, contentSize. Конец списка — когда contentOffset.y + layoutMeasurement.height дошёл до contentSize.height. Числа из события копируют сразу.
  • scrollEventThrottle={16} пишут всегда: без него песочница присылает только начало и конец прокрутки, а телефон — каждое событие. Обработчик onScroll обязан переживать повтор одного и того же смещения.
  • В ref у ScrollView — прокрутчик с scrollTo и scrollToEnd, а не узел. В состояние из onScroll кладут порог, а не смещение, иначе каждый кадр перерисовывает весь список.
  • Наивный список платит за невидимое: тысяча упражнений — тысяча вызовов строки и 2001 узел в разметке при четырёх видимых строках. Следующая статья рисует только окно.

1/4Список не прокручивается, а onLayout рамки печатает 900 — ровно столько же, сколько onContentSizeChange. Где искать причину?

2/4В песочнице список ведут вниз три кадра подряд, scrollEventThrottle не задан. Сколько раз сработает onScroll?

3/4Куда писать alignItems: "center", чтобы строки внутри ScrollView встали посередине?

4/4Справочник на 1000 упражнений отрисован через map внутри ScrollView, на экране 4 строки. Сколько раз вызовется компонент строки при первом показе?

Конец списка — по разу на приход

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

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

Нужна память о прошлом событии: были ли мы уже в конце. На экране она не отображается, перерисовка ей не нужна — значит, ref, а не состояние.

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

Строк становится две. Срабатывает не «в конце», а «при входе в конец»: переход из ложного в истинное. Досыл конца даёт истину поверх истины и молчит, уход к середине сбрасывает флаг. Такую же защиту ставит у себя FlatList в onEndReached: флаг сбрасывается, когда список уходит от края, и вдобавок сверяется длина содержимого. До onEndReached мы дойдём в последней статье главы.

Тысяча строк на каждый кадр

Заголовок над списком меняется, когда справочник прокручен дальше 120 точек. Работает — но каждое событие прокрутки перерисовывает всю тысячу строк. Добейтесь, чтобы после тех же трёх прокруток список перерисовался только там, где заголовок действительно менялся: монтирование плюс одна перерисовка на переходе порога, а прокрутки 100 и 300, где флаг остаётся прежним, списка не касались. Абсолют счётчика Row эталоном не служит: сколько событий прокрутки дойдёт до onScroll, решает машина — на медленных соседние события сливаются.

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

Заголовку нужно не смещение, а один факт — «дальше 120 или нет». Его и кладём в состояние:

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

С флагом счётчик Row растёт на тысячу при монтировании и ещё на тысячу на единственной смене порога — на 200: положение 100 кладёт false поверх false, 300 — true поверх true, и Object.is отменяет перерисовку. Без флага каждое новое смещение — новое значение состояния, и весь список перерисовывается на каждое доставленное событие прокрутки: тысяча вызовов за перерисовку. Абсолютное число при этом не эталон: на медленных машинах соседние события прокрутки сливаются, и доставленных событий меньше трёх, — сравнивайте прирост счётчика на «лишних» прокрутках, он с флагом должен стать нулевым.

Соблазн обернуть Row в memo здесь не сработает: makeCatalog(1000) вызывается в рендере и каждый раз отдаёт новые объекты, так что пропсы у строк всегда «другие». А главное — даже идеальная мемоизация не отменяет тысячи строк при монтировании. Это лечит только окно.

Сколько узлов в строке

Два списка по тысяче строк. В первом строка — View с двумя Text, во втором — один Text со вложенным Text для группы мышц. Предскажите оба числа и только потом запускайте. Потом ответьте: изменится ли соотношение на телефоне?

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

3001 и 2001: каждый View и каждый Text — отдельный элемент разметки, плюс внутренняя коробка ScrollView. Вложенный Text в react-native-web становится span внутри div, но элементом остаётся.

На телефоне разрыв больше. Вложенный Text там вообще не становится нативной вьюхой: весь абзац с вложениями собирается в одну строку с разметкой внутри и рисуется одним нативным текстовым компонентом. Так что второй вариант на устройстве — одна нативная вьюха на строку, а первый — до трёх. Но оба остаются тысячей строк при четырёх видимых: переставлять узлы внутри строки полезно, а избавиться от 996 невидимых строк можно только окном.