Размеры без пикселей: dp, проценты, PixelRatio
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Все размеры прошлой статьи — 300, 200, 120 — были числами без единиц, и onLayout возвращал их такими же. Пикселями их не назовёшь: ширина 300 на iPhone X займёт 900 точек матрицы, а на старом Android — 300. Разберём, в каких единицах живёт раскладка React Native, от чего считаются проценты-строки и почему привычные px, rem и calc здесь хуже ошибки, подстроим справочник «Подхода» под ширину окна через useWindowDimensions и закроем долг статьи про StyleSheet: почему hairlineWidth в предпросмотре всегда 1, а на телефоне — доля dp. Этаж нижний: единицы, Dimensions и PixelRatio — сам React Native, Expo здесь ни при чём. Запуск у статьи частичный: плотность телефона песочнице не изобразить, поэтому подложка научится подменять размер окна и плотность, а поведение телефона посчитает миниатюра — по формулам из исходника.
В CSS из части II у длины всегда есть единица: px, rem, %, vw. В стиле React Native у числа единицы нет, но и пикселями матрицы оно не является. На iOS такие числа — точки (points), на Android — dp, пиксели, не зависящие от плотности. В исходнике PixelRatio так и написано: размер раскладки — в dp, пиксельный размер — в px. Смысл у обеих платформ один: 1 dp — примерно одна и та же физическая величина на любом экране, а сколько точек матрицы за ней стоит, решает плотность. На Android dp отсчитывают от 160 точек на дюйм: у экрана 420 dpi плотность 420 / 160 = 2,625, и 1 dp там — 2,625 пикселя.
Веб-разработчику это знакомо: CSS-пиксель тоже не пиксель матрицы, и на экране с devicePixelRatio 2 блок в 100px занимает 200 физических точек. Поэтому react-native-web просто дописывает к числу px — в статье про StyleSheet width: 40 превратилось в width: 40px, — и предпросмотр выглядит верно. Плотность React Native отдаёт через PixelRatio.get(), и в обеих реализациях это одно и то же поле: Dimensions.get("window").scale.
| Где | Единица стиля | Плотность |
|---|---|---|
| iOS | точка (pt) | 2 или 3 у современных iPhone — экраны @2x и @3x |
| Android | dp | dpi / 160: 1, 1,5, 2, 2,625, 3, 3,5… |
| предпросмотр | CSS-пиксель | devicePixelRatio вашего экрана |
Плотность в песочнице — devicePixelRatio вашего экрана, у каждого читателя своя, и печатать её в примере бесполезно. Поэтому в подложке появился хелпер setDevice: он подменяет то, что кадр сообщает о своём окне и плотности, — примерно как режим устройства в инструментах разработчика браузера. Что не передано, остаётся прежним. Сам кадр при этом не меняется: раскладка идёт в его настоящей ширине, меняются только ответы Dimensions и PixelRatio, а Platform.OS как был "web", так и остаётся — подменяется экран, а не платформа. Прогноз: значок упражнения 24 × 24 и пять экранов — что сообщит onLayout и сколько физических пикселей займёт значок на каждом экране?
onLayout сообщил 24 × 24 и сообщит столько же на любом телефоне: раскладка живёт в dp, Yoga считает в dp и отдаёт их же. Физические пиксели появляются только на выходе — getPixelSizeForLayoutSize умножает на плотность и округляет до целого: 24, 36, 48, 72, 84. Строка onLayout пришла последней: цикл выполнился сразу, а событие раскладки приходит после отрисовки.
Физические пиксели нужны там, где размер уходит за пределы раскладки. Главный случай — картинки. Фото упражнения 48 × 48 на экране плотности 3 должно иметь 144 × 144 точки, иначе оно выйдет мыльным. Поэтому миниатюру с сервера просят размером getPixelSizeForLayoutSize(48), а не 48 — такой пример стоит прямо в комментарии к PixelRatio. Картинки из самого проекта кладут тремя файлами — icon.png, icon@2x.png, icon@3x.png, — и файл под плотность выбирает React Native; каким правилом, разберёт задача номер два.
Считает Yoga в дробных dp, а результат кладёт на сетку физических пикселей: в конце расчёта CalculateLayout.cpp вызывает roundLayoutResultsToPixelGrid, а шаг сетки задаёт плотность — на iOS RCTFabricSurface.mm передаёт pointScaleFactor = RCTScreenScale(), на Android SurfaceHandlerBinding.cpp — pixelDensity. Поэтому на телефоне плотности 3 onLayout отдаёт размеры, кратные трети dp. В предпросмотре округляет браузер, по своим правилам.
Второй способ задать размер без пикселей — проценты. В типе они записаны строго: DimensionValue — это число, "auto", строка вида `${number}%`, анимированное значение или null. Считается процент от родителя, как в CSS: ширина и высота — от внутренней области родителя, за вычетом его отступов и рамки, а padding — от ширины родителя, даже сверху и снизу. У Yoga так же: в Style.h отступ по любой оси разрешается от одной величины — ширины, параметра widthSize.
Карточка тренировки «Подхода»: колонка шириной 240, в ней карточка с отступом "5%", внутри квадратная миниатюра на четверть ширины, полоса прогресса «3 подхода из 4» и блок в полвысоты. aspectRatio — ещё одна величина без пикселей: отношение ширины к высоте, и 1 даёт квадрат. Прогноз: размеры карточки и трёх её детей?
Отступ "5%" посчитан от ширины колонки: 240 × 0,05 = 12, со всех четырёх сторон — сверху и снизу тоже от ширины. Миниатюра — 25% уже не от 240, а от того, что осталось внутри карточки: 240 − 2 × 12 = 216, четверть — 54, и aspectRatio: 1 вывел из ширины такую же высоту. Прогресс — 75% от тех же 216, то есть 162. Строку процента собрал шаблон `${(done / planned) * 100}%`, и компилятор его принял: шаблонная строка на месте типа `${number}%` получает этот тип. А блок height: "50%" схлопнулся в 0: высота карточки не задана, половину считать не от чего — в CSS и у Yoga одинаково. Карточка вышла 240 × 84: 12 + 54 + 6 + 0 + 12.
Проценты и flex занимают одну нишу — размер относительно родителя, — но считают по-разному. Процент берётся от родителя целиком, а flex делит свободное место — то, что осталось после gap, отступов и соседей с заданной шириной. Две колонки по "50%" в ряду 300 с gap: 12 вылезут за край на 12: по 150 каждая, и вторая начнётся с 162. Две колонки с flex: 1 поделят оставшиеся 288 по 144. Для сеток с промежутками надёжнее flex, процент — когда долю задают данные, как у прогресса.
Коварство процентов в том, что строка в width выглядит как CSS, и рука сама пишет туда другие единицы. Прогноз: какой ширины окажутся четыре полосы в колонке 300?
В предпросмотре три полосы из четырёх сработали: "50px", "2rem" и "calc(100% - 32px)" react-native-web отдал в CSS как есть, и браузер их понял — 50, 32 и 268. Четвёртая — наоборот: строку "50" без единицы CSS не принимает, браузер выбросил объявление, и полоса растянулась колонкой на все 300.
На телефоне всё переворачивается. Строку ширины разбирает C++-часть рендерера — fromRawValue в react-native/ReactCommon/react/renderer/components/view/conversions.h. Она сверяет строку с "auto" и ещё тремя ключевыми словами, а остальное разбирает как CSS, но принимает только два вида значений: голое число (CSSNumber) и процент (CSSPercentage). "50" — голое число, то есть 50 dp. "50px", "2rem" и calc — ни то, ни другое: ширина становится неопределённой, будто её не задавали, и колонка растягивает полосу на 300. Ни ошибки, ни предупреждения.
Остаются диагностики, по одной на каждую полосу: «Тип ""50px"" не может быть назначен для типа "DimensionValue | undefined"». Это тот случай, ради которого написана глава: рантайм молчит на обеих платформах, причём по-разному, и неверную строку ловит только компилятор с настоящими типами.
Значение width | Компилятор | Предпросмотр | Телефон |
|---|---|---|---|
50 | пропускает | 50 | 50 |
"50%" | пропускает | половина родителя | половина родителя |
"50px", "2rem", "calc(…)" | ошибка | как в CSS | ширины нет |
"50vw" | ошибка | половина ширины кадра | ширины нет |
"50" | ошибка | ширины нет | 50 |
Проценты и flex отвечают на вопрос «какая доля родителя». Но есть решения, которые зависят от экрана целиком: сколько колонок у справочника, показывать ли боковую панель. В вебе для этого медиазапросы, и пересчитывает их сам браузер — при повороте, при смене размера окна. В React Native медиазапросов нет: стиль — объект, пересчитывать его некому. Размер окна приходит числом — из Dimensions.get("window") или из хука useWindowDimensions, — а решение принимает ваш код.
Справочник «Подхода»: на узком окне одна колонка карточек, от 600 dp — две. Рядом баннер, чья ширина посчитана прямо в StyleSheet.create, — так пишут часто, пока не повернут телефон. Поворот изобразит setDevice. Эффект — как в части IV: срабатывает после рендера, в котором сменилась ширина. Прогноз: что он напечатает до поворота и после?
До поворота всё сходится: 393 − 32 = 361, и у карточки, и у баннера. После поворота хук вернул 852, компонент перерисовался, колонок стало две, карточка — (852 − 32 − 12) / 2 = 404. А баннер остался 361: его ширину вычислили один раз, когда выполнялся модуль, и в объекте стиля лежит просто число, без всякой подписки. Об этом предупреждает комментарий над Dimensions.get в исходнике React Native: размеры могут измениться, например при повороте, поэтому их не кешируют, а читают на каждом рендере — инлайн-стилем, а не в StyleSheet.
Но читать на каждом рендере мало — рендер ещё нужно вызвать. Это и делает useWindowDimensions. Вот он целиком, без типов:
Для прошедшего часть IV здесь нет ничего нового: состояние, эффект с подпиской и уборкой. Новое только источник — событие change у Dimensions. На телефоне его рождает нативная сторона сигналом didUpdateDimensions. На iOS, судя по RCTDeviceInfo.mm, сигнал уходит при повороте, при смене рамки окна — например, когда iPad делит экран с другим приложением, — и при смене размера текста в настройках. В предпросмотре react-native-web слушает resize у visualViewport — ровно этот сигнал и шлёт setDevice. Версия react-native-web проще: четыре поля она не сравнивает и кладёт новый объект при каждом событии.
Dimensions знает два размера. "window" — область приложения: на планшете, поделённом с другим приложением, она меньше экрана. "screen" — дисплей целиком. Раскладку строят по окну, поэтому хук отдаёт только его.
Четвёртое поле хука — fontScale: множитель размера текста из системных настроек, «Размер текста» на iOS и «Размер шрифта» на Android — так сказано в комментарии к PixelRatio.getFontScale. При множителе 1,3 текст с fontSize: 16 на телефоне вырастет до 16 × 1,3 = 20,8 dp: масштабирование включено, пока у Text не выключен allowFontScaling (по умолчанию true), а потолок ставит maxFontSizeMultiplier. В предпросмотре этого не увидеть: react-native-web пишет fontScale: 1 жёстко, а allowFontScaling и maxFontSizeMultiplier в разметку не передаёт вовсе.
Отсюда практическое правило: строке с текстом задают minHeight, а не height. Строка справочника высотой 44 при крупном системном шрифте обрежет название — высота в dp осталась прежней, а текст вырос. В предпросмотре такую строку не проверить: там fontScale всегда 1.
Осталась константа, обещанная ещё в статье про StyleSheet: StyleSheet.hairlineWidth — толщина самой тонкой линии платформы, для разделителей и рамок. В предпросмотре она всегда 1. На телефоне — нет, и вот как её считают обе реализации:
Рецепт телефона: взять 0,4 dp и округлить до целого числа физических пикселей — это и есть roundToNearestPixel. Если вышел ноль, берётся ровно один физический пиксель: 1 / PixelRatio.get(). Перепишем рецепт своей функцией densityFor(scale) — «PixelRatio телефона с такой-то плотностью» — и спросим у неё линию на шести экранах, а последней строкой сравним с настоящим StyleSheet в предпросмотре, изображающем плотность 3. Прогноз: сколько dp и сколько физических пикселей будет у линии на каждом экране и что скажет предпросмотр?
На пяти экранах из шести линия — ровно один физический пиксель, а в dp — дробь: 1, 0,67, 0,5, 0,38, 0,33. На плотности 1 округление дало ноль, и сработал запасной путь: 1 / 1 = 1. Исключение — плотность 4: 0,4 × 4 = 1,6 округляется до двух пикселей, и линия выходит 0,5 dp — толще, чем на экране плотности 3. Рецепт ищет не «один пиксель», а «около 0,4 dp, но целыми пикселями». А предпросмотр при плотности 3 ответил 1: PixelRatio react-native-web плотность знает, но hairlineWidth по ней не считает.
Причина — в комментарии над константой: браузеры могут округлить долю пикселя до нуля, и линия не нарисуется. Chrome 152, на котором проверялась статья, линию не теряет, но и тоньше не рисует: рамку 0.1px он округляет вверх до целого CSS-пикселя — при плотности 1, 2 и 3 вычисленная толщина одна и та же, 1px. Выходит, дробная константа в этом браузере ничего бы не дала, а в другом, по словам комментария, могла бы стереть линию, — и react-native-web не рискует. Цена видна глазами: разделитель толщиной hairlineWidth в предпросмотре — CSS-пиксель, то есть на экране плотности 3 три физических пикселя, втрое толще, чем на телефоне. Насколько лёгкие у списка разделители, по предпросмотру не судят.
Ещё две детали из того же исходника. Значение считается один раз, при первом обращении, и запоминается в _hairlineWidth. А комментарий над геттером прямо просит не считать его постоянным: на разных платформах и плотностях оно разное. Поэтому в стилях пишут StyleSheet.hairlineWidth, а не 0.5 или 1 / 3. Сама densityFor с этого места лежит в подложке рядом с setDevice и едет в следующие статьи: где предпросмотр отвечает за браузер, она ответит за телефон.
roundToNearestPixel из того же рецепта пригодится и самому — когда размер считает ваш код: 8,4 dp на плотности 3 превращаются в 8,33, ровно 25 физических пикселей. Этот пример взят из комментария к PixelRatio в исходнике React Native.
- Числа в стилях React Native — не пиксели матрицы, а dp (на iOS — точки): физических пикселей за каждым столько, какова плотность экрана.
onLayoutотдаёт dp, пиксели считаетPixelRatio.getPixelSizeForLayoutSize— для картинок и всего, что уходит за пределы раскладки. В предпросмотре dp — это CSS-пиксель, а плотность —devicePixelRatioвашего экрана. - Проценты пишут строкой вида
`${number}%`, и считаются они от внутренней области родителя;paddingв процентах — от ширины родителя, даже сверху и снизу.px,rem,vwиcalcв строке предпросмотр понимает, а телефон молча выбрасывает; строку"50"— наоборот. Ловит всё это только компилятор. - Медиазапросов нет: решения по размеру экрана принимают по
useWindowDimensions— он подписан на событиеchangeуDimensionsи перерисовывает компонент при повороте и смене окна. Ширина, прочитанная вStyleSheet.createили черезDimensions.getбез подписки, после поворота остаётся старой. - У хука четыре поля:
width,height,scaleиfontScale. Множитель шрифта из настроек телефона увеличивает текст, пока не выключенallowFontScaling; в предпросмотреfontScaleвсегда 1, поэтому строкам с текстом даютminHeight, а неheight. hairlineWidthна телефоне — 0,4 dp, округлённые до целых физических пикселей, а если вышел ноль — один пиксель: 0,5 на плотности 2, 0,333 на 3, 0,5 на 4. react-native-web ставит 1, опасаясь, что браузер округлит долю пикселя до нуля, поэтому разделители в предпросмотре толще телефонных. СвояdensityFor(scale)повторяет рецепт и лежит в подложке вместе сsetDevice.
1/4<View style={{ width: 100 }} /> на телефоне плотности 3. Что сообщит onLayout?
2/4В колонке шириной 300 стоит <View style={{ width: "50px", height: 8 }} />. Какой ширины будет полоса на телефоне?
3/4Экран читает ширину в StyleSheet.create на уровне модуля: width: Dimensions.get("window").width - 32. Что будет после поворота телефона?
4/4Сколько вернёт StyleSheet.hairlineWidth на телефоне плотности 3 и в предпросмотре на экране той же плотности?
Баннер после поворота
Баннер тренировки «Подхода» тянется во всю ширину с полями по 16. Ширину компонент читает на каждом рендере, как советует комментарий React Native, — и всё равно после поворота она не меняется: второй строки в выводе нет. Поправьте Banner так, чтобы после поворота он напечатал 820.
Чтение на рендере работает, только если рендер случится, а поворот его не вызывает: Dimensions.get ни на что компонент не подписывает. Подписку даёт хук:
Вывод становится «ширина баннера: 361», затем «ширина баннера: 820». На настоящем экране есть решение ещё лучше — не считать ширину вовсе: marginHorizontal: 16 без width, и колонка сама растянет баннер при любой ширине и любом повороте. В песочнице его поворотом не проверить: setDevice меняет только ответы Dimensions, а настоящий кадр не поворачивается. Считать от ширины окна стоит там, где решение дискретное: сколько колонок, какой вариант экрана.
Какой файл возьмёт телефон
Значок гантели лежит в проекте тремя файлами: dumbbell.png, dumbbell@2x.png и dumbbell@3x.png. Сборщик кладёт в приложение все три, а файл под экран выбирает функция pickScale из react-native/Libraries/Image/AssetUtils.js. Правило у неё такое: первая плотность из списка, которая не меньше плотности экрана; если такой нет — самая большая; если список пуст — 1. Допишите свою pickScale. Какой файл достанется экрану 2,625?
Вывод: экранам 1 — dumbbell.png, 1.5 и 2 — @2x, 2.625, 3 и 3.5 — @3x; последняя строка — «файлы 1x и 2x, экран 3: 2 | файлов нет: 1». Правило берёт картинку с запасом: экрану 2,625 достаётся @3x — уменьшенная картинка остаётся чёткой, а растянутая @2x вышла бы мыльной. На 3,5 запаса нет, и берётся самая большая.
Где правило работает. На iOS — всегда: getScaledAssetPath в AssetSourceResolver.js дописывает к имени @3x. На Android — когда картинку отдаёт Metro при разработке и когда JS-пакет загружен из файла; во втором случае плотность превращается в имя папки: 3 → drawable-xxhdpi. В обычной релизной сборке Android картинки лежат в ресурсах приложения, и файл по плотности выбирает уже сама система. Этаж Expo здесь тоже есть: expo-asset держит свою копию pickScale с тем же правилом. В предпросмотре файлов картинок нет вовсе — сборщика в песочнице нет.
Линия на экранах, которых не было
Не запуская, скажите, какой будет hairlineWidth в dp и в физических пикселях на трёх экранах Android — 120, 280 и 440 dpi — и во что roundToNearestPixel превратит 8,4 dp. densityFor лежит в подложке. Потом запустите и сверьтесь.
Плотности — 0,75, 1,75 и 2,75 (dpi / 160).
Сюрприз первой строки: на экране с плотностью меньше 1 самая тонкая линия толще 1 dp — 1,33. Рецепт держится за физический пиксель, а не за dp: тоньше одного пикселя линию не нарисовать. 8,4 dp превращаются в 8, 8,571… и 8,363… — ближайшие размеры, которые ложатся ровно в 6, 15 и 23 физических пикселя.