Документация
Раскладка
NSH размещает каждый элемент с помощью Taffy, реализации на Rust flexbox-, grid- и block-раскладки CSS, настроенной так, что дочерние элементы по умолчанию выстраиваются вертикально и не сжимаются. Раскладка выполняется в ядре после каждого такта вашего кода, и в нативные представления отправляются только изменившиеся кадры. Текст измеряется текстовым движком платформы в том же потоке, поэтому раскладка никогда не ждёт главного потока.
import { mount, Scroll, signal, Text, View } from "nsh";
import { button, buttonText, desktop, Inspector, tint, ui, wide } from "./palette";
type Direction = "column" | "row";
type Justify = "flex-start" | "center" | "flex-end" | "space-between" | "space-evenly";
type Align = "stretch" | "flex-start" | "center" | "flex-end";
function Choice<T extends string>(props: { label: string; options: readonly T[]; value: T; onChange: (v: T) => void }) {
return (
<View style={{ gap: 4 }}>
<Text style={{ fontSize: 12, fontWeight: "600", color: ui().muted }}>{props.label}</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
<Each of={props.options}>
{(o) => (
<View onPress={() => props.onChange(o())} {...button("secondary", { size: "sm", on: () => props.value === o() })}>
<Text style={buttonText("secondary", { on: () => props.value === o() })}>{o()}</Text>
</View>
)}
</Each>
</View>
</View>
);
}
function App() {
const direction = signal<Direction>("row");
const justify = signal<Justify>("flex-start");
const align = signal<Align>("stretch");
const colors = () => [ui().down, ui().info, ui().up];
const box = (height: number) => (
<View
style={{
height,
padding: 10,
gap: 8,
borderRadius: 12,
...ui().inset,
flexDirection: direction(),
justifyContent: justify(),
alignItems: align(),
}}
>
<Each of={colors()}>
{(c, i) => (
<View style={{ minWidth: 44, minHeight: 44, padding: 6, borderRadius: 8, backgroundColor: c() }}>
<Text style={{ color: ui().page, fontWeight: "700" }}>{i() + 1}</Text>
</View>
)}
</Each>
</View>
);
const more = () => (
<>
<Text style={{ fontSize: 12, fontWeight: "600", color: ui().muted }}>flex: 1 / 2 / fixed, percentages, aspectRatio</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<View style={{ flex: 1, height: 36, borderRadius: 6, backgroundColor: tint(ui().info, 0.3) }} />
<View style={{ flex: 2, height: 36, borderRadius: 6, backgroundColor: tint(ui().info, 0.6) }} />
<View style={{ width: 36, height: 36, borderRadius: 6, backgroundColor: ui().info }} />
</View>
<View style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
<View style={{ width: "30%", aspectRatio: 1, borderRadius: 8, backgroundColor: tint(ui().warn, 0.35) }} />
<View style={{ width: "40%", aspectRatio: 16 / 9, borderRadius: 8, backgroundColor: ui().warn }} />
</View>
<Text style={{ fontSize: 12, fontWeight: "600", color: ui().muted }}>grid</Text>
<View style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6 }}>
<View style={{ gridColumn: "span 2", height: 40, borderRadius: 6, backgroundColor: tint(ui().violet, 0.3) }} />
<View style={{ height: 40, borderRadius: 6, backgroundColor: tint(ui().violet, 0.6) }} />
<View style={{ gridRow: "span 2", borderRadius: 6, backgroundColor: ui().violet }} />
<View style={{ gridColumn: "1 / 4", height: 40, borderRadius: 6, backgroundColor: tint(ui().violet, 0.8) }} />
</View>
<Text style={{ fontSize: 12, fontWeight: "600", color: ui().muted }}>position: absolute</Text>
<View style={{ ...ui().inset, height: 100, borderRadius: 12 }}>
<View style={{ position: "absolute", top: 8, left: 8, width: 60, height: 40, borderRadius: 6, backgroundColor: ui().up }} />
<View style={{ position: "absolute", right: 8, bottom: 8, width: "40%", height: 30, borderRadius: 6, backgroundColor: ui().down }} />
<View style={{ position: "absolute", top: 0, bottom: 0, left: "50%", width: 2, backgroundColor: ui().border }} />
</View>
</>
);
const justifyChoice = () => (
<Choice
label="justifyContent"
options={["flex-start", "center", "flex-end", "space-between", "space-evenly"] as const}
value={justify()}
onChange={(v) => justify.set(v)}
/>
);
const alignChoice = () => (
<Choice label="alignItems" options={["stretch", "flex-start", "center", "flex-end"] as const} value={align()} onChange={(v) => align.set(v)} />
);
return (
<>
{() =>
wide() ? (
<View style={{ flex: 1, backgroundColor: ui().page, flexDirection: "row", padding: 20, paddingTop: 24, gap: 16 }}>
<Scroll style={{ flex: 1, minWidth: 0 }}>
<View style={{ gap: 14 }}>
{box(260)}
{more()}
</View>
</Scroll>
<Inspector>
<Choice label="flexDirection" options={["row", "column"] as const} value={direction()} onChange={(v) => direction.set(v)} />
{justifyChoice()}
{alignChoice()}
</Inspector>
</View>
) : (
<Scroll style={{ flex: 1, backgroundColor: ui().page }}>
<View style={{ padding: 20, paddingTop: 24, gap: 14 }}>
<View
onPress={() => direction.set(direction() === "row" ? "column" : "row")}
{...button("primary", { align: "flex-start" })}
>
<Text style={buttonText("primary")}>
flexDirection: {direction()} ({desktop ? "click" : "tap"} to switch)
</Text>
</View>
{justifyChoice()}
{alignChoice()}
{box(220)}
{more()}
</View>
</Scroll>
)
}
</>
);
}
mount(App);Перечисленные ниже свойства раскладки являются свойствами стиля. В Rust это типизированные методы стиля (.flex_row(), .gap_4(), .grid_cols(3), .absolute().inset_0()); в TSX это ключи пропа style, и у каждого есть класс Tailwind (flex-row, gap-4, grid-cols-3, absolute inset-0, …). Оба варианта приводятся к одним и тем же нативным стилям.
#Flexbox
Представление раскладывает дочерние элементы с помощью flexbox. Представление, в котором не задан display, выстраивает потомков в столбец; display: "flex" в стиле делает строку, если не задан flexDirection, точно как в CSS и как класс Tailwind flex. Если ничего из этого не задано, действуют следующие значения по умолчанию:
| Свойство | Значение по умолчанию в NSH | Значение по умолчанию в вебе |
|---|---|---|
flexDirection | "column" (представление, в котором не задан display); "row", как только задан display: "flex" | row |
flexShrink | 0 | 1 |
alignContent | "flex-start" (сетки: "normal") | normal |
alignItems | "stretch" | stretch |
minWidth / minHeight | 0 | auto |
position | "relative" | static |
view()
.flex_row()
.items_center()
.gap_3()
.p_4()
.child(image(avatar).size(px(40.0)).rounded(px(20.0)))
.child(
view()
.flex_1()
.gap_0p5()
.child(text(name).font_weight(FontWeight::SEMIBOLD))
.child(text(message).text_color(rgb(0x6e7781)).number_of_lines(1)),
)
.child(text(time).text_color(rgb(0x6e7781)))<View style={{ flexDirection: "row", alignItems: "center", gap: 12, padding: 16 }}>
<Image src={avatar} style={{ width: 40, height: 40, borderRadius: 20 }} />
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontWeight: "600" }}>{props.name}</Text>
<Text style={{ numberOfLines: 1, color: "#6e7781" }}>{props.message}</Text>
</View>
<Text style={{ color: "#6e7781" }}>{props.time}</Text>
</View>| Свойство | Значения | Rust |
|---|---|---|
flexDirection | "column", "row", "column-reverse", "row-reverse" | .flex_col(), .flex_row(), .flex_col_reverse(), .flex_row_reverse() |
justifyContent | Главная ось: "flex-start", "flex-end", "start", "end", "center", "space-between", "space-around", "space-evenly", "stretch" | .justify_start(), .justify_center(), .justify_end(), .justify_between(), .justify_around(), .justify_evenly() |
alignItems | Поперечная ось: "stretch", "flex-start", "flex-end", "start", "end", "self-start", "self-end", "center", "baseline" | .items_stretch(), .items_start(), .items_center(), .items_end(), .items_baseline() |
alignSelf | Выравнивание одного дочернего элемента по поперечной оси; "auto" следует alignItems родителя | .self_start(), .self_center(), .self_end(), .self_stretch() |
alignContent | Линии контейнера с переносом | |
flexWrap | "nowrap", "wrap", "wrap-reverse" | .flex_wrap() |
flex | Краткая запись одним числом (см. ниже) | .flex_1(), .flex_auto(), .flex_none() |
flexGrow, flexShrink, flexBasis | Как в CSS | .flex_grow(n), .flex_shrink(n), .flex_shrink_0(), .basis(length) |
gap, rowGap, columnGap | Промежутки между дочерними элементами | .gap(length), .gap_y(length), .gap_x(length) и шаги .gap_2(), .gap_x_4(), … |
flex-start и flex-end следуют направлению flex; start и end следуют направлению письма, как в CSS (при письме справа налево start означает правую сторону), а self-start / self-end следуют собственному направлению дочернего элемента.
flex является краткой записью CSS, как и классы Tailwind flex-*: число n означает рост на n, сжатие на 1 и начальный базис 0, поэтому соседние элементы flex: 1 (.flex_1() в Rust, flex-1) делят пространство поровну независимо от содержимого; "none" равно 0 0 auto (представление сохраняет свой размер), "auto" равно 1 1 auto, "initial" равно 0 1 auto, а рост, сжатие и базис можно записать явно ("2 0 50%", "1 1 120px"). Поскольку минимальные размеры по умолчанию равны 0, flex: 0 (0 1 0) сжимает представление до нуля, если minWidth: "auto" не сохраняет размер его содержимого.
view()
.flex_row()
.gap_2()
// One share.
.child(view().flex_1().h(px(36.0)).bg(rgb(0xc8e1ff)))
// Two shares.
.child(view().flex_grow(2.0).flex_shrink(1.0).basis(px(0.0)).h(px(36.0)).bg(rgb(0x79b8ff)))
// Fixed.
.child(view().size(px(36.0)).bg(rgb(0x0366d6)))<View style={{ flexDirection: "row", gap: 8 }}>
<View style={{ flex: 1, height: 36, backgroundColor: "#c8e1ff" }} /> {/* one share */}
<View style={{ flex: 2, height: 36, backgroundColor: "#79b8ff" }} /> {/* two shares */}
<View style={{ width: 36, height: 36, backgroundColor: "#0366d6" }} /> {/* fixed */}
</View>Поскольку минимальные размеры по умолчанию равны 0, текст flex: 1 в строке сжимается и переносится, а не выталкивает соседей. minWidth: "auto" / minHeight: "auto" (min-w-auto / min-h-auto, .min_w(auto()) в Rust) задаёт автоматический минимум CSS: сжимаемый элемент останавливается на наименьшем размере своего содержимого (для текста это самое длинное слово), как в вебе.
#Размеры
| Свойство | Значения | Rust |
|---|---|---|
width, height | Пункты, процент от родителя ("50%") или "auto" | .w(px(120.0)), .h(relative(0.5)), .w_full(), .size(px(44.0)), шаги .w_10(), … |
minWidth, maxWidth, minHeight, maxHeight | Пункты или проценты; для minWidth / minHeight также "auto", то есть наименьший размер содержимого | .min_w(..), .max_w(..), .min_h(..), .max_h(..) |
aspectRatio | Ширина, делённая на высоту: 1, 16 / 9 | .aspect_ratio(16.0 / 9.0), .aspect_square() |
padding, paddingHorizontal, paddingVertical, paddingTop, … | Пункты или проценты | .p(..), .px(..), .py(..), .pt(..), … и шаги .p_4(), .px_2(), … |
margin, marginHorizontal, marginVertical, marginTop, … | Пункты, проценты или "auto" | .m(..), .mx(..), .my(..), .mt(..), …, .mx(auto()) |
В Rust длина типизирована: px(16.0) равно 16 пунктам, relative(0.5) равно 50% от родителя, auto() равно auto. Методы-шаги следуют шкале отступов Tailwind, где один шаг равен 4 пунктам (.p_4() равно 16 пунктам, .gap_0p5() равно 2 пунктам).
Без заданного размера представление имеет размер своего содержимого (плюс отступы) и растягивается по поперечной оси родителя благодаря alignItems: "stretch" по умолчанию. Проценты считаются от размера родителя (для padding и margin, как в CSS, от его ширины).
aspectRatio вычисляет другое измерение по известному:
view().w_full().aspect_ratio(16.0 / 9.0).bg(black())<View style={{ width: "100%", aspectRatio: 16 / 9, backgroundColor: "#000000" }} />Изображения из ресурсов приложения имеют естественный размер и раскладываются по нему, если размер не задан (Изображения и ресурсы). У удалённых изображений размера нет, пока они не загрузятся: задавайте его явно.
#Grid
display: "grid" (.grid() в Rust) раскладывает дочерние элементы по дорожкам сетки CSS:
view()
.grid()
.grid_template_columns("repeat(3, 1fr)")
.gap_2()
.child(view().col_span(2).h(px(60.0)).bg(rgb(0xd8b9ff)))
.child(view().h(px(60.0)).bg(rgb(0xb083f0)))
.child(view().row_span(2).bg(rgb(0x8250df)))
.child(view().col_start(2).col_end(4).h(px(60.0)).bg(rgb(0x6639ba)))<View style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 8 }}>
<View style={{ gridColumn: "span 2", height: 60, backgroundColor: "#d8b9ff" }} />
<View style={{ height: 60, backgroundColor: "#b083f0" }} />
<View style={{ gridRow: "span 2", backgroundColor: "#8250df" }} />
<View style={{ gridColumn: "2 / 4", height: 60, backgroundColor: "#6639ba" }} />
</View>| Свойство | Значения | Rust |
|---|---|---|
gridTemplateColumns, gridTemplateRows | Списки дорожек CSS: "repeat(3, 1fr)", "200px 1fr", minmax(), auto-fill / auto-fit, имена линий | .grid_cols(3), .grid_rows(2), .grid_template_columns("200px 1fr"), .grid_template_rows(..) |
gridAutoColumns, gridAutoRows | Дорожки неявно создаваемых столбцов и строк | |
gridAutoFlow | "row", "column", "dense", "row dense", "column dense" | .grid_flow_col(), .grid_flow_dense() |
gridColumn, gridRow | "1 / 3", "span 2", номер или имя линии: "content-start / content-end", "2 col" (вторая линия с именем col), "span col" (до следующей линии с именем col) | .col_span(2), .row_span(2), .col_span_full() |
gridColumnStart, gridColumnEnd, gridRowStart, gridRowEnd | Номера или имена линий | .col_start(1), .col_end(3), .row_start(..), .row_end(..) (номера линий) |
justifyItems, justifySelf | Как элементы располагаются в своих областях вдоль строки: "start", "end", "center", "stretch", … | |
alignItems, alignSelf | Как элементы располагаются в своих областях вдоль столбца | .items_*(), .self_*() |
justifyContent, alignContent | Как дорожки располагаются в сетке вдоль строки и столбца | .justify_*() |
Шаблоны называют свои линии в квадратных скобках, по одному или нескольку имён на линию, и элементы размещаются по этим именам, как в CSS (в Rust по номерам линий):
view()
.grid()
.grid_template_columns("[full-start] 16px [content-start] 1fr [content-end] 16px [full-end]")
// content-start / content-end: lines 2 to 3.
.child(view().col_start(2).col_end(3).h(px(60.0)).bg(rgb(0xb083f0)))
// full-start / full-end: lines 1 to 4.
.child(view().col_start(1).col_end(4).h(px(60.0)).bg(rgb(0x8250df)))<View style={{ display: "grid", gridTemplateColumns: "[full-start] 16px [content-start] 1fr [content-end] 16px [full-end]" }}>
<View style={{ gridColumn: "content-start / content-end", height: 60, backgroundColor: "#b083f0" }} />
<View style={{ gridColumn: "full-start / full-end", height: 60, backgroundColor: "#8250df" }} />
</View>В сетке justifyContent и alignContent по умолчанию имеют значение CSS "normal": дорожки размером auto растягиваются на весь контейнер, поэтому сетка с высотой и без шаблона строк делит эту высоту между своими строками. Явно заданное значение ("start", "center", "space-between", …) размещает дорожки так же, как в CSS. Классы выравнивания задают ровно то, что задаёт Tailwind CSS: content-start (align-content: flex-start) и place-content-start прижимают дорожки к началу, а content-normal и justify-normal растягивают их, как в браузере.
display: "block" использует блочную раскладку CSS (дочерние элементы друг под другом, каждый шириной с контейнер), а display: "none" (.hidden()) убирает представление из раскладки.
#Абсолютное позиционирование
position: "absolute" (.absolute()) выводит представление из потока и размещает его с помощью top, right, bottom и left относительно родителя:
view()
.h(px(200.0))
.child(image(cover).absolute().inset_0())
.child(
view()
.absolute()
.left_3()
.right_3()
.bottom_3()
.child(text("Caption").text_color(white()).font_weight(FontWeight::BOLD)),
)<View style={{ height: 200 }}>
<Image src={cover} style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }} />
<View style={{ position: "absolute", left: 12, right: 12, bottom: 12 }}>
<Text style={{ color: "#ffffff", fontWeight: "700" }}>Caption</Text>
</View>
</View>Смещения принимают пункты и проценты (.top(..), .right(..), .bottom(..), .left(..), .inset(..) в Rust). Представление с left и right (или top и bottom) и без размера растягивается между ними. Более поздние соседи рисуются поверх более ранних; zIndex (.z_index(n)) меняет порядок.
#Измерение
onLayout (.on_layout(..)) сообщает кадр представления относительно родителя после каждой раскладки, в которой он изменился:
let width = signal(0.0_f32);
view()
.on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))
.child(text(move || format!("{} points wide", width.get())))const width = signal(0);
<View onLayout={({ bounds }) => width.set(bounds.size.width)}>
<Text>{width()} points wide</Text>
</View>Кадр задаётся в пунктах: Bounds<Points> в Rust, { bounds } в TSX, Bounds ({ origin: { x, y }, size: { width, height } }). Для всего окна читайте window() в Rust или windowSize() из nsh в TSX; оба учитывают поворот и изменение размера окна, а стиль, который их читает, меняется вместе с окном:
view().style_when(|| window().width > px(600.0), |s| s.flex_row())import { View } from "nsh";
import { windowSize } from "nsh";
<View style={{ flexDirection: windowSize().width > 600 ? "row" : "column" }} />Брейкпоинты Tailwind (sm:, md:, …) и portrait: / landscape: дают тот же результат без кода.
#Безопасные области
Корневое представление занимает весь экран, в том числе область под строкой состояния и индикатором «Домой». Добавляйте к экранам отступы по безопасным областям с помощью CSS env() (paddingTop: "env(safe-area-inset-top)", Tailwind pt-[env(safe-area-inset-top)], .p_safe_area() в Rust; см. Безопасная область) или читайте отступы через safe_area() в Rust либо safeArea() / safeAreaInsets() в TSX и применяйте их самостоятельно. Страницы стека навигации уже расположены под своим заголовком.
#Письмо справа налево
Направление раскладки приложения следует языку системы, а стиль direction представления ("ltr", "rtl" или "inherit"; .direction(LayoutDirection::Rtl) в Rust) задаёт направление его поддерева. Когда приложение раскладывается справа налево:
- строки идут справа;
leftиrightостаются на своих сторонах, как в браузере, и в ключах стиля, и в классах:paddingLeft,marginLeft,left,borderLeftWidth,borderTopLeftRadius,textAlign: "left"иpl-4,ml-2,left-0,border-l,rounded-tl-lg,text-leftостаются слева;- логические свойства следуют направлению, причём начало строки (inline start) находится справа:
marginStart,marginEnd,paddingStart,paddingEnd,start,end,borderStartWidth,borderEndColor,borderTopStartRadius, …,textAlign: "start"/"end"(ps-4,ms-2,start-0,border-s,rounded-ss-lg,text-startкак классы;.ms(..),.me(..),.ps(..),.pe(..),.border_s(..),.rounded_ss(..), … в Rust);marginHorizontalиpaddingHorizontalзадают обе стороны; - текст с
textAlign: "auto"выравнивается вправо, а нативные элементы управления зеркалируются.
view()
.flex_row()
.gap_2()
.child(view().ms_4().rounded_ss(px(12.0)).size(px(44.0)).bg(rgb(0x54aeff)))
let rtl = signal(false);
view()
.direction(move || if rtl.get() { LayoutDirection::Rtl } else { LayoutDirection::Ltr })
.on_press(move |_: &()| rtl.set(!rtl.get()))
.child(text(move || if rtl.get() { "Right to left" } else { "Left to right" }))import { signal, Text, View } from "nsh";
const rtl = signal(false);
<View style={{ direction: rtl() ? "rtl" : "ltr" }}>
<View style={{ flexDirection: "row", gap: 8 }}>
<View style={{ marginStart: 16, borderTopStartRadius: 12, width: 44, height: 44, backgroundColor: "#54aeff" }} />
</View>
<View onPress={() => rtl.set(!rtl())}>
<Text>{rtl() ? "Right to left" : "Left to right"}</Text>
</View>
</View>Изменения направления, языка или представления, применяются сразу, без перезапуска. Стиль direction также позволяет оставить одно представление и его потомков в собственном направлении, например телефонный номер слева направо. На Android приложениям, встраивающим NSH, нужен android:supportsRtl="true" в манифесте, чтобы нативные элементы управления зеркалировались.
#См. также
- Стили: как стили сочетаются между собой.
- Прокрутка и Списки: содержимое, которое больше экрана.
- Справочник по стилям: каждое свойство раскладки и его тип.