Flex с другими умолчаниями
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
В конце прошлой статьи название рядом с иконкой повело себя в предпросмотре иначе, чем поведёт на телефоне, и объяснение было обещано здесь. Раскладку в React Native считает не браузер, а Yoga — движок на C++, который живёт внутри самого React Native и реализует flexbox. Правила у него те же, что в CSS, а умолчания другие, и расхождений четыре: колонка вместо ряда, flexShrink 0 вместо 1, alignContent flex-start вместо stretch и border-box вместо content-box. Разберём каждое на экранах «Подхода», измеряя раскладку событием onLayout, найдём место, где предпросмотр ведёт себя как CSS, а не как телефон, и сложим все четыре в один объект — с ним кусок вёрстки переносится с веба без сюрпризов. Этаж у статьи нижний: Yoga и её умолчания — это сам React Native, Expo здесь ни при чём.
В вебе раскладка начинается с выбора: блок, строка, flex, grid. В React Native выбирать не из чего: у каждого View раскладка flex, и другой нет. В типах у display три значения — "flex", "none" и "contents"; блоков, строк и сеток нет вовсе. Писать display: "flex" не нужно — он уже стоит.
Но этот flex — колонка. Главная ось, вдоль которой идут дети и работает justifyContent, у View по умолчанию вертикальна, а поперечная, где работает alignItems, — горизонтальна. У display: flex в CSS наоборот: ряд — с этого начинается статья пролога «Flex: делим свободное место своими руками». Перенесём привычку буквально. Шапка тренировки: слева место под название, справа кнопка, а по вебу это justifyContent: "space-between" и alignItems: "center". Положение спросим у самого React Native: событие onLayout, знакомое по numberOfLines из прошлой статьи, приносит x, y, ширину и высоту вида относительно родителя. Прогноз: где окажутся название и кнопка в шапке «как в вебе» и где — с flexDirection: "row"?
Шапка «как в вебе» встала столбиком посередине. Оба правила сработали, только не по тем осям. alignItems: "center" центрирует по поперечной оси, а в колонке она горизонтальна: название сдвинулось на (300 − 120) / 2 = 90, кнопка — на 110. justifyContent: "space-between" раздаёт свободное место вдоль главной оси, то есть по вертикали, а высота шапки не задана и равна содержимому: раздавать нечего, и кнопка встала сразу под названием, на y 20. Одна строка flexDirection: "row" возвращает задуманное: название у левого края, кнопка у правого, и по высоте обе по центру — название опустилось на (32 − 20) / 2 = 6.
Для телефона колонка — удобное умолчание: большинство экранов — стопка сверху вниз. Поэтому ряд в React Native пишут явно, а колонку не пишут никогда. У той же пары есть следствие, которое встречается на каждом первом экране. alignItems по умолчанию stretch, как и в CSS, но растягивает он по поперечной оси, а в колонке это ширина. Любой View без заданной ширины в колонке берёт ширину родителя: полоса, карточка, кнопка. Кнопка «Сохранить» во весь экран — не ошибка стиля, а колонка со stretch; как её поправить, разберёт задача номер три.
В исходнике Yoga умолчания записаны прямо в полях класса Style — react-native/ReactCommon/yoga/yoga/style/Style.h: flexDirection_ = FlexDirection::Column, alignContent_ = Align::FlexStart, boxSizing_ = BoxSizing::BorderBox, а выше DefaultFlexShrink = 0.0f. Там же лежит и WebDefaultFlexShrink = 1.0f — для настройки useWebDefaults, которую React Native не включает.
Второе расхождение — в том, что делает ряд, когда детям тесно. В CSS у flex-элемента flex-shrink: 1: если сумма ширин больше контейнера, каждый уступает пропорционально своей ширине. В React Native flexShrink по умолчанию 0: никто не уступает, и лишнее вылезает за край. Прогноз: какой ширины будут чипы «грудь» и «спина» в ряду 200 — без стилей и с flexShrink: 1?
Без flexShrink чипы остались по 120, и второй вылез за край ряда на 40 — на экране примера розовый чип выходит за серую полосу ряда. С flexShrink: 1 они уступили поровну: 40 переполнения делятся пропорционально ширинам, ширины равны — по 20 с каждого, итого по 100. В CSS так вышло бы без всякого стиля.
Умолчание 0 означает простую вещь: заданный размер — обещание. Вид шириной 120 будет шириной 120, сколько бы соседей ни пришло. Цена — переполнение, которое видно не в коде, а только на экране, и чаще всего оно случается не с чипами, а с текстом. Строка справочника: значок и длинное название в ряду. Проверим её так же и заодно спросим у предпросмотра вычисленный flex-shrink текста и значка — через computed из подложки. Прогноз: какой ширины будет название в каждой строке?
Обе строки дали 160: название уступило значку ровно 40 — и с flexShrink, и без. Для предпросмотра это верно, для телефона — нет, и последние две строки объясняют почему. Умолчания Yoga react-native-web ставит классом только на View: его css-view-… из статьи про StyleSheet несёт flex-direction: column, flex-shrink: 0, align-content: flex-start и box-sizing: border-box. У Text свой класс, и flex-shrink в нём не задан, поэтому в ряду браузер сжимает текст по правилу CSS — с единицей.
На телефоне у текста тот же flexShrink 0, что у любого узла Yoga, а мерить его Yoga будет по ширине всего ряда: в computeFlexBasisForChild ребёнок без своей ширины получает режим FitContent с шириной родителя, то есть 200. Название займёт почти всю эту ширину — сколько выйдет у самой длинной строки при переносе в 200, — и вместе со значком вылезет за край почти на 40. Первая строка вывода на телефоне показала бы не 160, а число около 200; вторая — те же 160: с flexShrink: 1 текст уступит ровно столько, сколько не хватает. Это та самая ловушка из заметки в конце прошлой статьи: там название рядом с иконкой сжалось до 176 из 200, здесь — до 160, и оба раза сжимал CSS, а не Yoga. В предпросмотре её не увидеть, поэтому правило стоит выучить заранее: текст в ряду всегда получает flexShrink: 1.
Если текст в ряду обёрнут во View — название с подписью в столбик, например, — flexShrink: 1 нужен обёртке: в ряду стоит она, а не текст. Такой случай предпросмотр покажет честно, у View класс с flex-shrink: 0. Это задача номер один.
Третье умолчание спит, пока ряд не переносится. alignContent решает, как строки переноса делят свободное место поперёк: работает он только при flexWrap: "wrap" и только когда у контейнера есть лишняя высота. В CSS начальное значение align-content — normal, и у flex-контейнера оно ведёт себя как stretch: строки растягиваются и делят остаток поровну. В React Native умолчание — flex-start: строки прижаты к началу, весь остаток уходит вниз.
Фильтр справочника «Подхода» — чипы мышечных групп в панели заданной высоты. Чип 90 × 30 с полем 5, в строку входит два. Прогноз: на каком y окажется вторая строка чипов при умолчании и с alignContent: "stretch"?
При умолчании вторая строка стоит на y 45: первая строка — чип 30 и поля по 5, всего 40, и вторая начинается сразу за ней. Внизу панели остаются пустые 40. Со stretch эти 40 поделены между строками поровну: каждая стала 60, и вторая строка чипов сдвинулась на 65. Сами чипы не выросли — у них задана высота; растянулись строки, в которых они стоят. Не будь высота задана, чипы растянул бы ещё и alignItems: "stretch" — до высоты своей строки.
Это расхождение предпросмотр показывает честно: align-content: flex-start стоит в том же классе View. А на практике оно самое тихое из четырёх: перенос в контейнере заданной высоты встречается редко, а без лишней высоты flex-start и stretch дают одно и то же. Вспомнить о нём стоит, когда строки переноса не хотят расходиться по высоте панели. Двигает их alignContent — center, space-between и остальные значения из типа, — а не justifyContent: тот работает внутри строки.
Четвёртое расхождение — в том, что считать шириной. В CSS по умолчанию box-sizing: content-box: width задаёт ширину содержимого, а отступы и рамка прибавляются снаружи. В React Native — border-box: width задаёт внешнюю ширину вида, а отступы и рамка вычитаются изнутри. Прогноз: какие размеры сообщит onLayout у карточки 200 × 60 с отступом 12 и рамкой 2 и у её содержимого — при умолчании и с boxSizing: "content-box"?
При умолчании карточка ровно 200 × 60, как написано, а содержимому досталось 200 − 2 × 12 − 2 × 2 = 172 в ширину и 60 − 28 = 32 в высоту. С content-box наоборот: 200 × 60 получило содержимое, а карточка выросла на те же 28 — до 228 × 88. Так посчитал бы браузер для обычного div без сброса стилей.
Многие веб-разработчики этого расхождения не почувствуют: в вебе border-box давно принято включать сбросом * { box-sizing: border-box } — такая строка стоит даже в стилях страницы этой песочницы, а откуда она взялась, разобрано в прологе, в разделе «Коробка в четыре слоя». В React Native сброс не нужен: border-box — умолчание Yoga, а в предпросмотре его повторяет тот же класс View. Обратный путь в типах React Native 0.86 тоже есть — свойство boxSizing со значением "content-box". Yoga учитывает его и при подсчёте основы flex: Node::resolveFlexBasis прибавляет отступы и рамку, только когда стоит не border-box.
В предпросмотре boxSizing уходит в CSS как есть: react-native-web его не проверяет и не переводит. Поэтому content-box здесь считается так же, как на телефоне, и 228 × 88 из вывода — честная цифра для обоих.
Все четыре расхождения — умолчания, а не другие правила. Значит, поведение CSS возвращается обычным стилем: четыре ключа, поставленные в массиве style первыми. Всё, что задаст сам компонент, стоит правее и перекрывает их — правило «слева направо» из статьи про StyleSheet. Соберём такую обёртку — Div, «div с display: flex» — и прогоним одну вёрстку через View и через Div: карточку 200 с отступом 10 и рамкой 2, а в ней два чипа по 120. Как обёртка передаёт пропсы дальше — как в части IV; новое здесь только содержимое CSS_FLEX. Прогноз: размеры карточки и положение чипов в обоих случаях?
Одна вёрстка — две раскладки, и в разнице видны три ключа из четырёх. View сложил чипы в колонку, по 120 каждый; карточка шириной 200, а высотой 2 + 10 + 30 + 30 + 10 + 2 = 84. Div поставил чипы в ряд, и им стало тесно: внутри карточки 200, а чипам нужно 240, — flexShrink: 1 ужал каждого до 100. Сама карточка при content-box выросла до 224 × 54. Четвёртый ключ, alignContent, молчит: переноса нет. Во второй задаче он заговорит — с этого места CSS_FLEX и Div лежат в подложке.
Пригодится Div в одном случае: когда кусок вёрстки переносится с веба и его цифры уже выверены под CSS. Писать им весь экран не стоит — вёрстка React Native, которую прочитает другой разработчик, должна полагаться на умолчания React Native. А на всё остальное — таблица.
| Свойство | CSS | React Native | Где кусается | Предпросмотр |
|---|---|---|---|---|
flexDirection | row | column | justifyContent работает по вертикали, alignItems — по горизонтали; ряд пишут явно | как телефон |
flexShrink | 1 | 0 | в тесном ряду никто не уступает; текст рядом со значком вылезает за край | как телефон у View, как CSS у Text |
alignContent | normal, то есть stretch | flex-start | строки переноса не делят лишнюю высоту контейнера | как телефон |
boxSizing | content-box | border-box | width уже включает отступы и рамку | как телефон |
Пятое отличие — не умолчание, а запись: flex в React Native — одно число, а не сокращение из трёх. Yoga читает flex: 1 как flexGrow 1 и основу 0, а flexShrink оставляет нулём; отрицательное число, наоборот, уходит в flexShrink. react-native-web отдаёт flex: 1 браузеру как есть, и вычисленный стиль в предпросмотре — «1 1 0%». Разницы почти не видно: и Yoga, и CSS умножают flexShrink на основу, а при основе 0 сжимать нечего.
- Раскладку на телефоне считает Yoga: правила flexbox те же, что в CSS, а четыре умолчания другие. Каждый
View— flex-контейнер, другогоdisplayнет, и направление у него — колонка:justifyContentработает по вертикали,alignItems— по горизонтали. flexShrinkпо умолчанию 0: заданная ширина не уступает, и тесный ряд переполняется, а не сжимается. Текст рядом со значком всегда получаетflexShrink: 1— предпросмотр эту ошибку не покажет: react-native-web ставитflex-shrink: 0только классуView.alignContentпо умолчаниюflex-start: строки переноса прижаты к началу и не делят лишнюю высоту. Заметно только приflexWrapи заданной высоте контейнера.border-boxбез всякого сброса:widthвключает отступы и рамку, аcontent-boxвключается свойствомboxSizing. В колонкеalignItems: "stretch"тянет детей по ширине — отсюда кнопки во весь экран и лекарствоalignSelf.- Все четыре — умолчания, поэтому поведение CSS возвращается объектом из четырёх ключей, поставленным в
styleпервым. ТакойDivудобен для вёрстки, перенесённой с веба, но экран пишут на умолчаниях React Native.flexздесь — одно число: у Yogaflex: 1— это рост 1 и основа 0.
1/4Контейнер без flexDirection, у него justifyContent: "space-between", внутри название и кнопка, высота не задана. Где окажется кнопка?
2/4Ряд шириной 200, в нём два View по 120 без других стилей. Какой ширины они будут на телефоне?
3/4Длинное название в ряду рядом со значком: в предпросмотре оно умещается и переносится, а на телефоне вылезает за край. Почему?
4/4<View style={{ width: 200, padding: 12, borderWidth: 2 }} />. Какую ширину сообщит onLayout?
Вес уехал за край
Строка упражнения в «Подходе»: значок 40, блок названия с подписью в столбик и вес шириной 50, ряд — 240. На телефоне вес уезжает за край экрана, и здесь, в отличие от голого текста, предпросмотр это показывает. Поправьте один стиль так, чтобы блок названия занял оставшиеся 150, а название в нём обрезалось многоточием.
Блоку названия нужно разрешить уступать:
Вывод становится «блок названия: 150» и «вес в ряду: да». Уступает именно блок: в ряду стоит он, а текст — ребёнок колонки, растянутый по ширине блока. flexShrink: 1 на самом Text ничего не даст — проверьте, вывод останется прежним. Сработает и flex: 1: основа 0 плюс рост занимают ровно оставшееся место, вывод тот же.
Почему до правки пример печатает не ширину, а «шире 150»: она разная на разных движках. Браузер берёт для View с flex-basis: auto ширину названия в одну строку, и сколько это пикселей, решает шрифт. Yoga отдаст блоку почти всю ширину ряда, до 240, как тексту во втором разделе. Вес уезжает в обоих случаях.
Три панели — три раскладки строк
Прогноз без запуска. Пять чипов 90 × 30 в панели 200 × 150 — строки переноса по два, два и один. Панелей три: View с рядом, Div из подложки (в нём первым стоит CSS_FLEX) и View с alignContent: "space-between". На каком y начнётся каждая строка в каждой панели? Потом запустите и сверьтесь.
Строк три по 30, вместе 90; лишней высоты 150 − 90 = 60.
От View здесь Div отличается только alignContent: направление у панели и так row, чипы — обычные View с заданной шириной, а без отступа и рамки content-box ничего не меняет. Последняя строка в панели space-between стоит вплотную к низу: 120 + 30 = 150.
Кнопка во всю ширину
Под итогом тренировки — кнопка «Сохранить», и в колонке она растянулась на все 300. Нужна кнопка по ширине надписи у правого края, а заголовок пусть остаётся слева. Добавьте один ключ в стиль кнопки.
Вывод: «во всю ширину: нет» и «правый край: 300». Растягивал кнопку alignItems: "stretch" родителя: умолчание то же, что в CSS, но в колонке поперечная ось горизонтальна. alignSelf переопределяет его для одного ребёнка. alignItems: "flex-end" на родителе тоже снял бы растяжение, но прижал бы вправо и заголовок. Кнопку по ширине надписи у левого края даёт alignSelf: "flex-start".