Documentazione
Layout
NSH dispone ogni elemento con Taffy, un’implementazione in Rust di flexbox, grid e block layout di CSS, configurata in modo che per impostazione predefinita i figli si impilino verticalmente e non si restringano. Il layout viene eseguito nel core, dopo ogni turno del tuo codice, e solo i frame che sono cambiati vengono inviati alle view native. Il testo viene misurato dal motore di testo della piattaforma sullo stesso thread, quindi il layout non attende mai il thread principale.
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);Le proprietà di layout qui sotto sono proprietà di stile. In Rust sono metodi di stile tipizzati (.flex_row(), .gap_4(), .grid_cols(3), .absolute().inset_0()); in TSX sono chiavi della prop style, e ciascuna ha una classe Tailwind (flex-row, gap-4, grid-cols-3, absolute inset-0, …). Entrambe si traducono negli stessi stili nativi.
#Flexbox
Una view dispone i propri figli con flexbox. Una view che non imposta display impila i figli in colonna; display: "flex" in uno stile crea una riga a meno che non sia impostato flexDirection, esattamente come in CSS e come la classe flex di Tailwind. Dove nulla le imposta, si applicano questi valori predefiniti:
| Proprietà | Default di NSH | Default del web |
|---|---|---|
flexDirection | "column" (una view che non imposta display); "row" una volta impostato display: "flex" | row |
flexShrink | 0 | 1 |
alignContent | "flex-start" (griglie: "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>| Proprietà | Valori | Rust |
|---|---|---|
flexDirection | "column", "row", "column-reverse", "row-reverse" | .flex_col(), .flex_row(), .flex_col_reverse(), .flex_row_reverse() |
justifyContent | Asse principale: "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 | Asse trasversale: "stretch", "flex-start", "flex-end", "start", "end", "self-start", "self-end", "center", "baseline" | .items_stretch(), .items_start(), .items_center(), .items_end(), .items_baseline() |
alignSelf | Allineamento sull’asse trasversale di un singolo figlio; "auto" segue alignItems del genitore | .self_start(), .self_center(), .self_end(), .self_stretch() |
alignContent | Righe di un contenitore con wrapping | |
flexWrap | "nowrap", "wrap", "wrap-reverse" | .flex_wrap() |
flex | Una scorciatoia a numero singolo (sotto) | .flex_1(), .flex_auto(), .flex_none() |
flexGrow, flexShrink, flexBasis | Come in CSS | .flex_grow(n), .flex_shrink(n), .flex_shrink_0(), .basis(length) |
gap, rowGap, columnGap | Spazio tra i figli | .gap(length), .gap_y(length), .gap_x(length) e i passi .gap_2(), .gap_x_4(), … |
flex-start e flex-end seguono la direzione flex; start e end seguono la direzione di scrittura, come in CSS (da destra a sinistra, start è il lato destro), e self-start / self-end quella propria del figlio.
flex è la scorciatoia di CSS, come lo sono le classi flex-* di Tailwind: un numero n significa crescere di n, restringersi di 1 e partire da una basis pari a 0, quindi i fratelli flex: 1 (.flex_1() in Rust, flex-1) si dividono lo spazio in parti uguali qualunque sia il loro contenuto; "none" è 0 0 auto (la view mantiene la propria dimensione), "auto" è 1 1 auto, "initial" è 0 1 auto, e grow, shrink e basis possono essere scritti esplicitamente ("2 0 50%", "1 1 120px"). Poiché le dimensioni minime sono 0 per impostazione predefinita, flex: 0 (0 1 0) riduce una view a nulla a meno che minWidth: "auto" non mantenga la dimensione del suo contenuto.
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>Poiché le dimensioni minime sono 0 per impostazione predefinita, un testo flex: 1 in una riga si restringe e va a capo invece di spingere fuori i fratelli. minWidth: "auto" / minHeight: "auto" (min-w-auto / min-h-auto, .min_w(auto()) in Rust) è il minimo automatico di CSS: un elemento che si restringe si ferma alla dimensione minima del proprio contenuto, la parola più lunga di un testo, come sul web.
#Dimensioni
| Proprietà | Valori | Rust |
|---|---|---|
width, height | Punti, una percentuale del genitore ("50%") oppure "auto" | .w(px(120.0)), .h(relative(0.5)), .w_full(), .size(px(44.0)), i passi .w_10(), … |
minWidth, maxWidth, minHeight, maxHeight | Punti o percentuali; minWidth / minHeight accettano anche "auto", la dimensione minima del contenuto | .min_w(..), .max_w(..), .min_h(..), .max_h(..) |
aspectRatio | Larghezza divisa per altezza: 1, 16 / 9 | .aspect_ratio(16.0 / 9.0), .aspect_square() |
padding, paddingHorizontal, paddingVertical, paddingTop, … | Punti o percentuali | .p(..), .px(..), .py(..), .pt(..), …, e i passi .p_4(), .px_2(), … |
margin, marginHorizontal, marginVertical, marginTop, … | Punti, percentuali oppure "auto" | .m(..), .mx(..), .my(..), .mt(..), …, .mx(auto()) |
In Rust una lunghezza è tipizzata: px(16.0) vale 16 punti, relative(0.5) è il 50% del genitore, auto() è auto. I metodi a passi seguono la scala di spaziatura di Tailwind, di 4 punti per passo (.p_4() vale 16 punti, .gap_0p5() 2 punti).
Senza una dimensione, una view è grande quanto il suo contenuto (più il padding), estesa lungo l’asse trasversale del genitore dal valore predefinito di alignItems: "stretch". Le percentuali sono relative alla dimensione del genitore (per padding e margin, come in CSS, alla sua larghezza).
aspectRatio dimensiona l’altra dimensione a partire da quella nota:
view().w_full().aspect_ratio(16.0 / 9.0).bg(black())<View style={{ width: "100%", aspectRatio: 16 / 9, backgroundColor: "#000000" }} />Le immagini degli asset dell’app hanno una dimensione naturale e vengono disposte con essa quando non ne viene indicata una (Immagini e asset). Le immagini remote non hanno dimensione finché non vengono caricate: assegna loro una dimensione.
#Griglia
display: "grid" (.grid() in Rust) dispone i figli in tracce di una griglia 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>| Proprietà | Valori | Rust |
|---|---|---|
gridTemplateColumns, gridTemplateRows | Liste di tracce CSS: "repeat(3, 1fr)", "200px 1fr", minmax(), auto-fill / auto-fit, nomi di linea | .grid_cols(3), .grid_rows(2), .grid_template_columns("200px 1fr"), .grid_template_rows(..) |
gridAutoColumns, gridAutoRows | Tracce di colonne e righe create implicitamente | |
gridAutoFlow | "row", "column", "dense", "row dense", "column dense" | .grid_flow_col(), .grid_flow_dense() |
gridColumn, gridRow | "1 / 3", "span 2", un numero o un nome di linea: "content-start / content-end", "2 col" (la seconda linea chiamata col), "span col" (fino alla linea successiva chiamata col) | .col_span(2), .row_span(2), .col_span_full() |
gridColumnStart, gridColumnEnd, gridRowStart, gridRowEnd | Numeri o nomi di linea | .col_start(1), .col_end(3), .row_start(..), .row_end(..) (numeri di linea) |
justifyItems, justifySelf | Come gli elementi si posizionano nelle proprie aree lungo la riga: "start", "end", "center", "stretch", … | |
alignItems, alignSelf | Come gli elementi si posizionano nelle proprie aree lungo la colonna | .items_*(), .self_*() |
justifyContent, alignContent | Come le tracce si posizionano nella griglia lungo la riga e la colonna | .justify_*() |
I template danno un nome alle proprie linee tra parentesi quadre, uno o più nomi per linea, e gli elementi vengono posizionati in base a quei nomi, come in CSS (in Rust, in base ai numeri delle linee):
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>In una griglia, justifyContent e alignContent hanno come valore predefinito "normal" di CSS: le tracce dimensionate auto si estendono fino a riempire il contenitore, quindi una griglia con un’altezza e senza template di righe divide quell’altezza tra le proprie righe. Un valore impostato esplicitamente ("start", "center", "space-between", …) posiziona le tracce come in CSS. Le classi di allineamento impostano esattamente ciò che imposta Tailwind CSS: content-start (align-content: flex-start) e place-content-start raggruppano le tracce all’inizio, mentre content-normal e justify-normal le estendono, come in un browser.
display: "block" usa il block layout di CSS (figli impilati, ciascuno largo quanto il contenitore), e display: "none" (.hidden()) rimuove la view dal layout.
#Posizionamento assoluto
position: "absolute" (.absolute()) toglie una view dal flusso e la posiziona tramite top, right, bottom e left, relativamente al suo genitore:
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>Gli offset accettano punti e percentuali (.top(..), .right(..), .bottom(..), .left(..), .inset(..) in Rust). Una view con left e right (oppure top e bottom) e senza dimensione si estende tra i due. I fratelli successivi vengono disegnati sopra quelli precedenti; zIndex (.z_index(n)) cambia l’ordine.
#Misurazione
onLayout (.on_layout(..)) riporta il frame di una view, relativo al suo genitore, dopo ogni layout in cui è cambiato:
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>Il frame è espresso in punti: Bounds<Points> in Rust, { bounds } in TSX, un Bounds ({ origin: { x, y }, size: { width, height } }). Per l’intera finestra, leggi window() in Rust oppure windowSize() da nsh in TSX; entrambi seguono la rotazione e il ridimensionamento della finestra, e uno stile che li legge cambia insieme alla finestra:
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" }} />I breakpoint di Tailwind (sm:, md:, …) e portrait: / landscape: danno lo stesso risultato senza codice.
#Aree sicure
La view radice copre l’intero schermo, sotto la barra di stato e l’indicatore home. Applica alle schermate un padding pari agli inset dell’area sicura con env() di CSS (paddingTop: "env(safe-area-inset-top)", pt-[env(safe-area-inset-top)] di Tailwind, .p_safe_area() in Rust; vedi Area sicura), oppure leggi gli inset con safe_area() in Rust o safeArea() / safeAreaInsets() in TSX e applicali tu stesso. Le pagine di uno stack di navigazione si trovano già sotto la propria intestazione.
#Da destra a sinistra
La direzione del layout dell’app segue la lingua di sistema, e lo stile direction di una view ("ltr", "rtl" o "inherit"; .direction(LayoutDirection::Rtl) in Rust) imposta la direzione del suo sottoalbero. Quando l’app viene disposta da destra a sinistra:
- le righe procedono da destra;
lefterightmantengono i propri lati, come in un browser, sia nelle chiavi di stile sia nelle classi:paddingLeft,marginLeft,left,borderLeftWidth,borderTopLeftRadius,textAlign: "left"epl-4,ml-2,left-0,border-l,rounded-tl-lg,text-leftrestano a sinistra;- le proprietà logiche seguono la direzione, con l’inizio inline che è il lato destro:
marginStart,marginEnd,paddingStart,paddingEnd,start,end,borderStartWidth,borderEndColor,borderTopStartRadius, …,textAlign: "start"/"end"(ps-4,ms-2,start-0,border-s,rounded-ss-lg,text-startcome classi;.ms(..),.me(..),.ps(..),.pe(..),.border_s(..),.rounded_ss(..), … in Rust);marginHorizontalepaddingHorizontalimpostano entrambi i lati; - il testo con
textAlign: "auto"si allinea a destra, e i controlli nativi vengono specchiati.
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>Le modifiche alla direzione, della lingua o di una view, si applicano subito, senza riavvio. Lo stile direction mantiene inoltre una view e i suoi figli in una direzione propria, ad esempio un numero di telefono da sinistra a destra. Su Android, le app che incorporano NSH richiedono android:supportsRtl="true" nel proprio manifest affinché i controlli nativi vengano specchiati.
#Correlati
- Styling: come si combinano gli stili.
- Scrolling e Liste: contenuti più grandi dello schermo.
- Riferimento degli stili: ogni proprietà di layout e il suo tipo.