이 페이지는 아직 번역되지 않았습니다. 영어 원문 보기

문서

Layout

NSH lays out every element with Taffy, a Rust implementation of CSS flexbox, grid and block layout, configured so that children stack vertically and do not shrink by default. Layout runs in the core, after each turn of your code, and only the frames that changed are sent to the native views. Text is measured by the platform’s text engine on the same thread, so layout never waits for the main thread.

guide-layout.tsx
guide-layout.tsxLive
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);

The layout properties below are style properties. In Rust they are typed style methods (.flex_row(), .gap_4(), .grid_cols(3), .absolute().inset_0()); in TSX they are keys of the style prop, and each has a Tailwind class (flex-row, gap-4, grid-cols-3, absolute inset-0, …). Both lower to the same native styles.

#Flexbox

A view lays out its children with flexbox. A view that sets no display stacks its children in a column; display: "flex" in a style makes a row unless flexDirection is set, exactly as in CSS and as Tailwind’s flex class. Where nothing sets them, these defaults apply:

PropertyNSH defaultWeb default
flexDirection"column" (a view that sets no display); "row" once display: "flex" is setrow
flexShrink01
alignContent"flex-start" (grids: "normal")normal
alignItems"stretch"stretch
minWidth / minHeight0auto
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)))
PropertyValuesRust
flexDirection"column", "row", "column-reverse", "row-reverse".flex_col(), .flex_row(), .flex_col_reverse(), .flex_row_reverse()
justifyContentMain axis: "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()
alignItemsCross axis: "stretch", "flex-start", "flex-end", "start", "end", "self-start", "self-end", "center", "baseline".items_stretch(), .items_start(), .items_center(), .items_end(), .items_baseline()
alignSelfOne child’s cross-axis alignment; "auto" follows the parent’s alignItems.self_start(), .self_center(), .self_end(), .self_stretch()
alignContentLines of a wrapping container
flexWrap"nowrap", "wrap", "wrap-reverse".flex_wrap()
flexA single-number shorthand (below).flex_1(), .flex_auto(), .flex_none()
flexGrow, flexShrink, flexBasisAs in CSS.flex_grow(n), .flex_shrink(n), .flex_shrink_0(), .basis(length)
gap, rowGap, columnGapSpace between children.gap(length), .gap_y(length), .gap_x(length), and the steps .gap_2(), .gap_x_4(), …

flex-start and flex-end follow the flex direction; start and end follow the writing direction, as in CSS (right to left, start is the right side), and self-start / self-end the child’s own.

flex is CSS’s shorthand, as Tailwind’s flex-* classes are: a number n means grow by n, shrink by 1 and start from a basis of 0, so flex: 1 siblings (.flex_1() in Rust, flex-1) share the space equally whatever their content; "none" is 0 0 auto (the view keeps its size), "auto" is 1 1 auto, "initial" is 0 1 auto, and grow, shrink and basis can be written out ("2 0 50%", "1 1 120px"). As minimum sizes default to 0, flex: 0 (0 1 0) shrinks a view to nothing unless minWidth: "auto" keeps its content’s size.

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)))

Because minimum sizes default to 0, a flex: 1 text in a row shrinks and wraps instead of pushing its siblings out. minWidth: "auto" / minHeight: "auto" (min-w-auto / min-h-auto, .min_w(auto()) in Rust) is CSS’s automatic minimum: a shrinking item stops at its content’s smallest size, the longest word of a text, as on the web.

#Sizes

PropertyValuesRust
width, heightPoints, a percentage of the parent ("50%"), or "auto".w(px(120.0)), .h(relative(0.5)), .w_full(), .size(px(44.0)), the steps .w_10(), …
minWidth, maxWidth, minHeight, maxHeightPoints or percentages; minWidth / minHeight also "auto", the content’s smallest size.min_w(..), .max_w(..), .min_h(..), .max_h(..)
aspectRatioWidth divided by height: 1, 16 / 9.aspect_ratio(16.0 / 9.0), .aspect_square()
padding, paddingHorizontal, paddingVertical, paddingTop, …Points or percentages.p(..), .px(..), .py(..), .pt(..), …, and the steps .p_4(), .px_2(), …
margin, marginHorizontal, marginVertical, marginTop, …Points, percentages, or "auto".m(..), .mx(..), .my(..), .mt(..), …, .mx(auto())

In Rust a length is typed: px(16.0) is 16 points, relative(0.5) is 50% of the parent, auto() is auto. The step methods follow Tailwind’s spacing scale of 4 points per step (.p_4() is 16 points, .gap_0p5() 2 points).

Without a size, a view is as big as its content (plus padding), stretched across its parent’s cross axis by the default alignItems: "stretch". Percentages are relative to the parent’s size (for padding and margin, as in CSS, to its width).

aspectRatio sizes the other dimension from the one that is known:

view().w_full().aspect_ratio(16.0 / 9.0).bg(black())

Images from the app’s assets have a natural size and lay out at it when no size is given (Images and assets). Remote images have no size until they load: give them one.

#Grid

display: "grid" (.grid() in Rust) lays out children in CSS grid tracks:

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)))
PropertyValuesRust
gridTemplateColumns, gridTemplateRowsCSS track lists: "repeat(3, 1fr)", "200px 1fr", minmax(), auto-fill / auto-fit, line names.grid_cols(3), .grid_rows(2), .grid_template_columns("200px 1fr"), .grid_template_rows(..)
gridAutoColumns, gridAutoRowsTracks of implicitly created columns and rows
gridAutoFlow"row", "column", "dense", "row dense", "column dense".grid_flow_col(), .grid_flow_dense()
gridColumn, gridRow"1 / 3", "span 2", a line number or name: "content-start / content-end", "2 col" (the second line named col), "span col" (up to the next line named col).col_span(2), .row_span(2), .col_span_full()
gridColumnStart, gridColumnEnd, gridRowStart, gridRowEndLine numbers or names.col_start(1), .col_end(3), .row_start(..), .row_end(..) (line numbers)
justifyItems, justifySelfHow items sit in their areas along the row: "start", "end", "center", "stretch", …
alignItems, alignSelfHow items sit in their areas along the column.items_*(), .self_*()
justifyContent, alignContentHow the tracks sit in the grid along the row and the column.justify_*()

Templates name their lines in brackets, one or several names per line, and items are placed by those names, as in CSS (in Rust, by the lines’ numbers):

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)))

In a grid, justifyContent and alignContent default to CSS’s "normal": tracks sized auto stretch to fill the container, so a grid with a height and no row template shares that height among its rows. A value set explicitly ("start", "center", "space-between", …) places the tracks as it does in CSS. The alignment classes set exactly what Tailwind CSS sets: content-start (align-content: flex-start) and place-content-start pack the tracks at the start, and content-normal and justify-normal stretch them, as in a browser.

display: "block" uses CSS block layout (children stacked, each as wide as the container), and display: "none" (.hidden()) removes the view from layout.

#Absolute positioning

position: "absolute" (.absolute()) takes a view out of the flow and places it by top, right, bottom and left, relative to its parent:

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)),
    )

Offsets accept points and percentages (.top(..), .right(..), .bottom(..), .left(..), .inset(..) in Rust). A view with left and right (or top and bottom) and no size stretches between them. Later siblings are drawn above earlier ones; zIndex (.z_index(n)) changes the order.

#Measuring

onLayout (.on_layout(..)) reports a view’s frame, relative to its parent, after each layout in which it changed:

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())))

The frame is in points: Bounds<Points> in Rust, { bounds } in TSX, a Bounds ({ origin: { x, y }, size: { width, height } }). For the whole window, read window() in Rust or windowSize() from nsh in TSX; both follow rotation and window resizing, and a style that reads them changes with the window:

view().style_when(|| window().width > px(600.0), |s| s.flex_row())

Tailwind’s breakpoints (sm:, md:, …) and portrait: / landscape: give the same result without code.

#Safe areas

The root view covers the whole screen, under the status bar and the home indicator. Pad screens by the safe area insets with CSS’s env() (paddingTop: "env(safe-area-inset-top)", Tailwind pt-[env(safe-area-inset-top)], .p_safe_area() in Rust; see Safe area), or read the insets with safe_area() in Rust or safeArea() / safeAreaInsets() in TSX and apply them yourself. Pages of a navigation stack already sit below their header.

#Right to left

The app’s layout direction follows the system language, and a view’s direction style ("ltr", "rtl" or "inherit"; .direction(LayoutDirection::Rtl) in Rust) sets the direction of its subtree. When the app lays out right to left:

  • rows run from the right;
  • left and right keep their sides, as in a browser, in style keys and classes alike: paddingLeft, marginLeft, left, borderLeftWidth, borderTopLeftRadius, textAlign: "left" and pl-4, ml-2, left-0, border-l, rounded-tl-lg, text-left stay on the left;
  • the logical properties follow the direction, inline start being the right side: 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 as classes; .ms(..), .me(..), .ps(..), .pe(..), .border_s(..), .rounded_ss(..), … in Rust); marginHorizontal and paddingHorizontal set both sides;
  • text with textAlign: "auto" aligns right, and native controls mirror.
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" }))

Changes to the direction, the language’s or a view’s, apply at once, without a restart. The direction style also keeps one view and its children in a direction of their own, for example a phone number left to right. On Android, apps that embed NSH need android:supportsRtl="true" in their manifest for native controls to mirror.