Секции, липкие шапки и пустое состояние
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Справочник на FlatList — одна лента из ста двадцати упражнений подряд. В зале же ищут не по алфавиту, а по группе мышц: сегодня спина — значит, листаем «Спину». Разложим справочник по группам на SectionList, узнаем в нём ту же плоскую ленту, где шапка группы — такая же строка, приклеим шапки к краю рамки и выясним, почему без флага на iPhone они липнут, а на Android и в песочнице — нет. В конце — пустота: если поиск ничего не нашёл, экран должен сказать это словами, а не показать белое поле или шапки без строк. SectionList — нижний этаж, сам React Native, поэтому всё в статье выполняется в песочнице.
FlatList из прошлых статей показывает справочник одной лентой. Для ленты с группами у React Native есть SectionList — с тем же окном внутри и почти теми же пропами. Вместо data он берёт sections — массив секций. Секция — обычный объект, у которого обязательно одно поле, data, — строки группы; остальное ваше, у нас это title. renderItem рисует строку, как у FlatList, а renderSectionHeader — шапку группы и получает объект с полем section.
Секции собирает обычная функция: пройти справочник и разложить упражнения по группам. Прогноз: сколько шапок и сколько строк окажется в первой отрисовке — и какой index получит «Подтягивания широким хватом», первое упражнение «Спины» и шестнадцатое в справочнике?
Шапка одна, строк девять — вместе десять. initialNumToRender считает не упражнения, а ячейки, и шапка группы — такая же ячейка, как строка. Первая порция — те же десять, что у FlatList: шапка «Грудь» и девять упражнений под ней.
А index — 0. В renderItem приходит номер строки внутри её секции, а не во всём справочнике. Рядом с item и index лежат section — сама секция со всеми вашими полями — и separators, как у FlatList. Сквозного номера в аргументе нет. Если он нужен — например, чтобы чередовать фон строк без сброса в начале каждой группы, — его считают сами.
Компилятор песочницы отметит здесь item и index: «Свойство "item" не существует в типе "SectionListRenderItemInfo<Exercise, Section>"». Тип аргумента renderItem собирается из ListRenderItemInfo пакета @react-native/virtualized-lists, которого в наборе типов песочницы нет, и от него остаётся одно поле section. В проекте item выводится из sections сам, и запись ({ item }) — обычная; пример выполняется и здесь.
С этой статьи toSections живёт в подложке и объявляется в примерах через declare, как makeCatalog. Там же новый помощник, seenAtTop, — он понадобится в следующем разделе.
Шапка группы полезна, пока её видно. Прокрутили «Грудь» до середины — и уже непонятно, чьи упражнения на экране. Поэтому шапку делают липкой: она стоит у верхнего края рамки, пока её не вытеснит шапка следующей группы. У SectionList за это отвечает проп stickySectionHeadersEnabled.
Проверять будем тем, что видит глаз у верхнего края. Помощник rowAtTop из прошлой статьи здесь не годится: он читает раскладку строк, а липкая шапка лежит поверх строки, и по раскладке у края оказываются обе. Поэтому seenAtTop спрашивает браузер, что нарисовано в этой точке. В разметку, как и прежде, заглядывает подложка, а не пример. Ниже два одинаковых справочника: у второго флаг включён, у первого не задан вовсе. Прогноз: что будет у верхнего края каждой рамки на смещениях 200 и 560 — и что напечатает Platform.OS?
Без флага шапки уехали вместе со строками: у края упражнения, «Разводка гантелей лёжа» и «Подтягивания обратным хватом». С флагом у края «Грудь», а на 560 — уже «Спина»: шапка «Спины» дошла до верха и вытеснила «Грудь».
Главное — первая строка вывода. Значение флага по умолчанию в SectionList.js — одно выражение: stickySectionHeadersEnabled ?? Platform.OS === 'ios'. Оно одинаковое в React Native 0.86 и в react-native-web. На iPhone шапки липнут сами, на Android — нет, а в песочнице Platform.OS — "web", и она ведёт себя как Android. Выходит, один и тот же экран без флага на двух телефонах выглядит по-разному. Поэтому флаг пишут всегда, явно: true или false — как решил дизайн, а не платформа.
Устроена липкость на платформах по-разному, хотя видно одно и то же. react-native-web оборачивает шапку во View со стилем position: "sticky" — стиль stickyHeader в ScrollView/index.js, — и шапку держит браузер. React Native заворачивает её в ScrollViewStickyHeader — Animated.View, который сдвигается через transform: translateY вслед за значением прокрутки. Значение привязано к нативному событию прокрутки через attachNativeEvent, поэтому шапка едет, не дожидаясь JS на каждом кадре. Из чего сделано такое значение, соберёт глава 8.
В шапке VirtualizedSectionList.js прямо сказано: пока что всё просто сплющивается в один список и отдаётся VirtualizedList. То есть SectionList — это то же окно, что у FlatList, а шапки групп — такие же строки плоской ленты. Липкость — проп stickyHeaderIndices у ScrollView: номера строк, которые должны липнуть. Соберём это сами: сплющим секции в одну ленту, запомним места шапок и отдадим FlatList. Рядом — настоящий SectionList с включённым флагом. Прогноз: сколько строк в ленте, на каких местах шапки и что будет у края обеих рамок на 560?
128 строк: 120 упражнений и 8 шапок. Шапки стоят через 16 — шапка и пятнадцать упражнений группы. У края обеих рамок «Спина»: для липких групп хватило плоской ленты и номеров шапок. Компилятор песочницы отметит stickyHeaderIndices у FlatList: проп приходит из @react-native/virtualized-lists, как и testID, поэтому свою ленту мы прокручиваем её же методом. В проекте это обычные пропы, а пример выполняется и здесь.
От нашей миниатюры настоящий SectionList отличается двумя вещами. Первая — подвал. На каждую секцию VirtualizedSectionList кладёт в ленту шапку, строки и ещё ячейку подвала — даже если renderSectionFooter не задан: в его render число ячеек так и считается, по две на секцию сверх строк. У справочника их 8 × 17 = 136, а не 128. Пустой подвал ничего не рисует, но ячейка у него есть, — это всплывёт в разделе про пустоту.
Вторая — ключ. Ключ ячейки собирает метод _subExtractor: ключ секции, двоеточие, ключ строки. Ключ строки — ваш keyExtractor, а без него поле key, id или номер, как у FlatList. Ключ секции — её поле key, а если его нет — её номер, String(i). Номер секции — такой же ключ по месту, как индекс в обычном списке (часть IV, «Своя сверка: что на самом деле делает key»). Пока секции не меняются, этого не видно. Но фильтр убирает целые группы.
Ниже два справочника с одним фильтром «Только тяги». У обоих keyExtractor — id упражнения, у второго у каждой секции ещё и key — её название. Прогноз: сколько строк смонтируется заново в каждом после нажатия?
После фильтра строк пятнадцать, и без key у секций заново смонтированы все пятнадцать. В «Груди» тяг нет, группа исчезла, и «Спина» из секции 1 стала секцией 0: ключ 1:ex-16 превратился в 0:ex-16 — и так у каждой строки каждой следующей группы. Для React это другие строки: старые размонтированы вместе с состоянием, новые смонтированы. С key: title ключ Спина:ex-16 остался прежним, и заново смонтированы только три строки — те, которых до фильтра в окне не было: две тяги из «Плеч» и одна из «Ягодиц». keyExtractor здесь не помогает ничем: он задаёт только часть ключа после двоеточия.
Отсюда правило: секциям, которые могут пропадать и переставляться, — свой key, уникальный, потому что из него строятся ключи всех ячеек секции. В «Подходе» это название группы. Во что обходится забытый key, когда у строки есть своё состояние, — первая задача.
В статье про FlatList своё упражнение получило номер из справочника, и React ругался на повтор ключей. Та же пара упражнений, но в разных секциях. Прогноз: будет ли в консоли предупреждение?
Нет. Ключи ячеек — 0:ex-1 и 1:ex-1, приставка секции их развела. Внутри одной секции повтор React поймает, как у FlatList: в песочнице это та же строка «Encountered two children with the same key…», только ключ в ней записан как .$0=2ex-1 — так React.Children экранирует двоеточие. Разводить свои и чужие номера приставкой, как во второй задаче статьи про FlatList, в SectionList не нужно, если они в разных секциях: приставку ставит он сам.
Справочник, в котором поиск ничего не нашёл, — самое частое пустое место приложения. Список без строк не рисует ничего: на телефоне это белое поле, и человек не знает, сломалось что-то, грузится или правда пусто. Для этого у SectionList, как и у FlatList, есть проп ListEmptyComponent — компонент или готовый элемент, который список рисует вместо строк. Условие в render у VirtualizedList одно: ячеек ноль.
Тут и ловушка. Поиск можно сделать двумя путями: отфильтровать упражнения внутри каждой группы или сначала отфильтровать справочник, а потом разложить по группам. Прогноз для двух справочников ниже — запроса нет ни в одной группе: нарисуются ли шапки, покажется ли пустота и сколько узлов окажется в разметке каждого?
У первого пустоты нет, зато нарисованы шапки — все восемь групп, и ни одной строки под ними. Секция с пустым data всё равно даёт две ячейки, шапку и подвал, так что для VirtualizedList список не пуст: ячеек шестнадцать. Узлов 26 — это они и есть: восемь шапок по два узла (ячейка и Text), восемь пустых подвалов по одному и два узла ScrollView, рамка и внутренняя коробка. Второй справочник пуст по-настоящему: ноль ячеек, и на месте строк — Text пустоты, три узла вместе с рамкой и коробкой.
Правило: пустых групп в sections не бывает. Проще всего, как во втором справочнике, сначала фильтровать и потом группировать, а готовые секции — отсеивать по data.length. Это же лечит шапки без строк, которые обманывают и без всякого поиска: «Руки» в заголовке, а под ним пусто. И это же сдвигает номера секций — вот зачем нужен key из прошлого раздела.
Что писать в пустоте. Осмысленная пустота отвечает на три вопроса: что пусто, почему и что делать дальше. «По запросу „скакалка с утяжелением“ ничего нет» и кнопка «Сбросить поиск» — осмысленная пустота. «Нет данных» — нет. Разные причины — разные слова: пустой поиск и ещё не заведённые свои упражнения — это третья задача. А «ещё грузится» — отдельное состояние экрана, его разбирала часть IV («use() и Suspense: ожидание как часть разметки»). Показать «ничего не нашлось», пока данные в пути, — неправда.
Пустоту хочется поставить посередине рамки: View с flex: 1 и центровкой. Прогноз: дотянется ли пустота до высоты рамки 120 точек — у списка как есть и у списка с contentContainerStyle, где flexGrow: 1?
Высота «как есть» — высота одной строки текста, и она зависит от шрифта платформы, поэтому пример печатает не число, а сравнение с рамкой: пустота прижата к верху рамки, и центровать ей не в чем. flex: 1 растягивает по родителю, а родитель пустоты — внутренняя коробка ScrollView, и высота коробки — ровно её содержимое (две коробки из первой статьи главы). Растягивать нужно коробку: с flexGrow: 1 она занимает всю рамку, а пустота с flex: 1 — всю коробку, 120. Запись та же и на телефоне: там те же две коробки и тот же contentContainerStyle.
Именно flexGrow, а не flex: 1. flex: 1 разрешает коробке ещё и сжиматься до рамки: в песочнице у полного справочника с таким стилем onContentSizeChange сообщает 120, хотя строки уходят на тысячу с лишним точек вниз. flexGrow: 1 только дотягивает коробку до рамки, а когда строк много, она растёт вместе с ними.
Сведём в одно место, чем секции в песочнице отличаются от секций на телефоне. Каждая строка сверена с исходниками, левая колонка — ещё и запуском выше.
| Что | В песочнице (react-native-web 0.21) | На телефоне (React Native 0.86) |
|---|---|---|
| Липкие шапки без флага | выключены: по умолчанию флаг равен Platform.OS === 'ios', а Platform.OS здесь "web" | iOS — включены, Android — выключены; строка в SectionList.js та же |
| Как держится липкая шапка | обёртка View со стилем position: "sticky", top: 0, zIndex: 10 — держит браузер | ScrollViewStickyHeader: Animated.View с translateY от значения прокрутки, привязанного к нативному событию через attachNativeEvent |
| Ячейки секции | шапка, строки и подвал — подвал даже без renderSectionFooter; пустая секция — две ячейки | то же самое, код VirtualizedSectionList совпадает |
ListEmptyComponent | только когда ячеек ноль: секции с пустым data его не пускают | то же самое |
| Ключ ячейки | ключ секции, двоеточие, ключ строки; ключ секции — key или номер | то же самое |
scrollToLocation дальше измеренного без getItemLayout | исключение «scrollToIndex should be used in conjunction with getItemLayout or onScrollToIndexFailed…» | то же исключение: прыжок идёт через тот же scrollToIndex |
Вызовы renderItem при дорисовке | ячейка CellRenderer — Component: каждая пачка вызывает renderItem заново для всех нарисованных строк | ячейка — PureComponent: вызов на строку |
| Типы | у аргумента renderItem только section: item и index — ошибка компилятора; у FlatList ошибкой отмечен stickyHeaderIndices | аргумент — ListRenderItemInfo с полем section, item выводится из sections |
Правило то же, что в прошлых статьях: код пишется так, чтобы быть верным в обеих колонках. Флаг липкости — явный. Пустых секций нет, у секций — key. Пустота растянута через flexGrow и говорит, что пусто и почему. Прыжки к группам — с getItemLayout, где шапка и подвал тоже ячейки; это вторая задача. Последняя статья главы — лента, которая подгружает страницы по onEndReached.
SectionListберётsections— массив объектов с обязательнымdata; шапку рисуетrenderSectionHeader. ВrenderItemприходятitem,index— номер внутри секции, —sectionиseparators.- Внутри — та же плоская лента
VirtualizedList: на секцию шапка, строки и ячейка-подвал, даже безrenderSectionFooter.initialNumToRenderсчитает ячейки, и шапка — тоже ячейка. - Липкие шапки по умолчанию — только на iOS:
Platform.OS === 'ios'. На Android и в песочнице они уезжают, поэтомуstickySectionHeadersEnabledзадают явно. В вебе шапку держитposition: sticky, на телефоне —ScrollViewStickyHeaderнаAnimated. - Ключ ячейки — ключ секции, двоеточие и ключ строки; без
keyключ секции — её номер. Пропала группа — все следующие строки монтируются заново, иkeyExtractorне спасает. У секций — свойkey. ListEmptyComponentрисуется, только когда ячеек ноль: пустые секции дают шапки без строк и прячут пустоту. Пустые группы отбрасывают, пустоту растягивают черезflexGrow: 1вcontentContainerStyleи пишут в ней, что пусто, почему и что делать.
1/4В renderItem у SectionList пришёл index: 0. Что это за строка?
2/4У SectionList не задан stickySectionHeadersEnabled. Где шапки групп будут липнуть к краю?
3/4Поиск отфильтровал data внутри каждой секции и ничего не нашёл, ListEmptyComponent задан. Что увидит человек?
4/4У секций нет key, у строк есть keyExtractor по id. Фильтр убрал первую группу. Что станет со строками остальных групп?
Фильтр стирает отметку
Отметьте «Подтягивания широким хватом» и включите «Только тяги». keyExtractor на месте, ключ строки — упражнение, а отметка после фильтра пропадает. Почините одной правкой, не трогая Row.
Отметку стёр не keyExtractor, а номер секции. В «Груди» тяг нет, группа ушла, «Спина» стала секцией 0, и ключ строки сменился с 1:ex-16 на 0:ex-16. React размонтировал строку вместе с её useState и смонтировал новую, неотмеченную. Ключом секции должна быть сама группа:
Теперь «✓» в обеих строках: ключ Спина:ex-16 фильтр не трогает. Но отметка по-прежнему живёт в строке, а строка за краем окна размонтируется (статья «Собираем окно: список, который рисует только видимое»): уедет далеко — отметка пропадёт уже по другой причине. Настоящее место отметок — выше списка, в хранилище главы 6.
Прыжок к группе
Кнопка ведёт к группе «Кардио» методом scrollToLocation({ sectionIndex, itemIndex }). itemIndex считается внутри секции вместе с шапкой: 0 — сама шапка. До «Кардио» список ещё не дорисовал и не измерил, и прыжок бросает то же исключение, что scrollToIndex в прошлой статье. Добавьте getItemLayout: шапка и строка — по 30 точек. Помните, в каких номерах он считает.
getItemLayout у SectionList получает не номер строки в секции, а номер ячейки плоской ленты — той, что в разделе «Внутри — та же лента»: на каждую секцию шапка, строки и подвал. scrollToLocation в этих же номерах и прыгает: itemIndex плюс по data.length + 2 за каждую секцию перед целевой. Раскладку всех ячеек удобно посчитать один раз:
Теперь «вверху: Кардио». Подвал нулевой высоты ничего не сдвигает, но номер занимает. Забудьте его — и массив окажется на восемь ячеек короче списка, а номера съедут на ячейку с каждой секцией; в песочнице прыжок тогда останавливается на «Спине». Появятся renderSectionFooter или разделители — их высоты войдут в расчёт так же, как в прошлой статье.
Одна пустота на две причины
Два списка «Подхода»: свои упражнения, которых человек ещё не завёл, и справочник, где поиск ничего не нашёл. Пустота у обоих одна и та же. Сделайте так, чтобы каждая говорила, что пусто и почему, — без второго компонента пустоты.
Причину знает список, а не пустота: у одного пуст источник, у другого — результат поиска. Пустота получает текст пропом, а в ListEmptyComponent уходит не компонент, а готовый элемент:
Теперь строки разные: в пустоте: Своих упражнений пока нет — добавьте первое и в пустоте: По запросу «скакалка с утяжелением» ничего нет. В приложении к каждой пустоте добавляют действие — кнопку «Добавить упражнение» у своих и «Сбросить поиск» у справочника — и растягивают её на всю рамку через contentContainerStyle с flexGrow: 1, как в разделе про пустоту.