指南
布局
nsh 使用 Taffy 为每个元素做布局。Taffy 是 CSS flexbox、网格和块布局的 Rust 实现,nsh 对它做了配置,让子元素默认纵向排列且不收缩。布局在核心中运行,发生在每一轮 JavaScript 执行之后,只有发生变化的 frame 会被发送到原生视图。文字由平台的文本引擎在同一线程上测量,因此布局从不需要等待主线程。
import { mount, Scroll, signal, Text, View } from "nsh";
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: "#6e7781" }}>{props.label}</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6 }}>
<Each of={props.options}>
{(o) => (
<View
onPress={() => props.onChange(o())}
style={{ paddingHorizontal: 10, paddingVertical: 5, borderRadius: 6, backgroundColor: props.value === o() ? "#0969da" : "#ffffff" }}
>
<Text style={{ fontSize: 12, color: props.value === o() ? "#ffffff" : "#1f2328" }}>{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 = ["#fd8c73", "#54aeff", "#4ac26b"];
return (
<Scroll style={{ flex: 1, backgroundColor: "#f6f8fa" }}>
<View style={{ padding: 20, paddingTop: 24, gap: 14 }}>
<View
onPress={() => direction.set(direction() === "row" ? "column" : "row")}
style={{ alignSelf: "flex-start", paddingHorizontal: 12, paddingVertical: 6, borderRadius: 6, backgroundColor: "#0969da" }}
>
<Text style={{ fontSize: 13, color: "#ffffff" }}>flexDirection: {direction()} (tap to switch)</Text>
</View>
<Choice
label="justifyContent"
options={["flex-start", "center", "flex-end", "space-between", "space-evenly"] as const}
value={justify()}
onChange={(v) => justify.set(v)}
/>
<Choice label="alignItems" options={["stretch", "flex-start", "center", "flex-end"] as const} value={align()} onChange={(v) => align.set(v)} />
<View
style={{
height: 220,
padding: 10,
gap: 8,
borderRadius: 12,
backgroundColor: "#ffffff",
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: "#ffffff", fontWeight: "700" }}>{i() + 1}</Text>
</View>
)}
</Each>
</View>
<Text style={{ fontSize: 12, fontWeight: "600", color: "#6e7781" }}>flex: 1 / 2 / fixed, percentages, aspectRatio</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
<View style={{ flex: 1, height: 36, borderRadius: 6, backgroundColor: "#c8e1ff" }} />
<View style={{ flex: 2, height: 36, borderRadius: 6, backgroundColor: "#79b8ff" }} />
<View style={{ width: 36, height: 36, borderRadius: 6, backgroundColor: "#0366d6" }} />
</View>
<View style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
<View style={{ width: "30%", aspectRatio: 1, borderRadius: 8, backgroundColor: "#ffdfb6" }} />
<View style={{ width: "40%", aspectRatio: 16 / 9, borderRadius: 8, backgroundColor: "#ffab70" }} />
</View>
<Text style={{ fontSize: 12, fontWeight: "600", color: "#6e7781" }}>grid</Text>
<View style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 6 }}>
<View style={{ gridColumn: "span 2", height: 40, borderRadius: 6, backgroundColor: "#d8b9ff" }} />
<View style={{ height: 40, borderRadius: 6, backgroundColor: "#b083f0" }} />
<View style={{ gridRow: "span 2", borderRadius: 6, backgroundColor: "#8250df" }} />
<View style={{ gridColumn: "1 / 4", height: 40, borderRadius: 6, backgroundColor: "#6639ba" }} />
</View>
<Text style={{ fontSize: 12, fontWeight: "600", color: "#6e7781" }}>position: absolute</Text>
<View style={{ height: 100, borderRadius: 12, backgroundColor: "#ffffff" }}>
<View style={{ position: "absolute", top: 8, left: 8, width: 60, height: 40, borderRadius: 6, backgroundColor: "#4ac26b" }} />
<View style={{ position: "absolute", right: 8, bottom: 8, width: "40%", height: 30, borderRadius: 6, backgroundColor: "#fd8c73" }} />
<View style={{ position: "absolute", top: 0, bottom: 0, left: "50%", width: 2, backgroundColor: "#d0d7de" }} />
</View>
</View>
</Scroll>
);
}
mount(App);下面的布局属性都是样式属性,每个都有对应的 Tailwind 写法(flex-row、gap-4、grid-cols-3、absolute inset-0 等)。
#Flexbox
视图用 flexbox 排列子元素。默认值与 Web 不同:
| 属性 | nsh 默认值 | Web 默认值 |
|---|---|---|
flexDirection | "column" | row |
flexShrink | 0 | 1 |
alignContent | "flex-start" | stretch |
alignItems | "stretch" | stretch |
minWidth / minHeight | 0 | auto |
position | "relative" | static |
<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={{ color: "#6e7781" }} numberOfLines={1}>{props.message}</Text>
</View>
<Text style={{ color: "#6e7781" }}>{props.time}</Text>
</View>
| 属性 | 取值 |
|---|---|
flexDirection | "column"、"row"、"column-reverse"、"row-reverse" |
justifyContent | 主轴:"flex-start"、"flex-end"、"center"、"space-between"、"space-around"、"space-evenly"、"stretch" |
alignItems | 交叉轴:"stretch"、"flex-start"、"flex-end"、"center"、"baseline" |
alignSelf | 单个子元素在交叉轴上的对齐;"auto" 跟随父元素的 alignItems |
alignContent | 换行容器中各行的对齐 |
flexWrap | "nowrap"、"wrap"、"wrap-reverse" |
flex | 单个数字的简写(见下文) |
flexGrow、flexShrink、flexBasis | 与 CSS 相同 |
gap、rowGap、columnGap | 子元素之间的间距 |
flex 只取一个数字,不是 CSS 简写:正数 n 表示按 n 增长、按 1 收缩、基准尺寸为 0,因此 flex: 1 的兄弟元素无论内容多少都会平分空间;flex: 0(默认值)让视图按内容确定大小;负数表示按该值收缩,直到最小尺寸。
<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 的文字会收缩并换行,而不会把兄弟元素挤出去。
#尺寸
| 属性 | 取值 |
|---|---|
width、height | 点、相对父元素的百分比("50%")或 "auto" |
minWidth、maxWidth、minHeight、maxHeight | 点或百分比 |
aspectRatio | 宽除以高:1、16 / 9 |
padding、paddingHorizontal、paddingVertical、paddingTop 等 | 点或百分比 |
margin、marginHorizontal、marginVertical、marginTop 等 | 点、百分比或 "auto" |
不指定尺寸时,视图的大小等于内容(加上内边距),并因默认的 alignItems: "stretch" 在父元素的交叉轴上被拉伸。百分比相对于父元素的尺寸(内边距和外边距与 CSS 一样,相对于父元素的宽度)。
aspectRatio 根据已知的一边计算另一边:
<View style={{ width: "100%", aspectRatio: 16 / 9, backgroundColor: "#000000" }} />
从文件导入的图片有自然尺寸,未指定尺寸时按自然尺寸布局(图片与资源)。远程图片在加载完成前没有尺寸:请为它们指定尺寸。
#网格
display: "grid" 把子元素排进 CSS 网格轨道:
<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>
| 属性 | 取值 |
|---|---|
gridTemplateColumns、gridTemplateRows | CSS 轨道列表:"repeat(3, 1fr)"、"200px 1fr"、minmax()、auto-fill / auto-fit、线名 |
gridAutoColumns、gridAutoRows | 隐式创建的列和行的轨道 |
gridAutoFlow | "row"、"column"、"dense"、"row dense"、"column dense" |
gridColumn、gridRow | "1 / 3"、"span 2"、线编号或线名 |
gridColumnStart、gridColumnEnd、gridRowStart、gridRowEnd | 线编号或线名 |
justifyItems、justifySelf | 项目在其区域内沿行方向的位置 |
display: "block" 使用 CSS 块布局(子元素依次堆叠,每个都与容器同宽),display: "none" 则把视图从布局中移除。
#绝对定位
position: "absolute" 让视图脱离文档流,并根据 top、right、bottom、left 相对父元素定位:
<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>
偏移量接受点和百分比。同时设置了 left 和 right(或 top 和 bottom)而没有设置尺寸的视图,会在两者之间拉伸。靠后的兄弟元素绘制在靠前的之上;zIndex 可以改变这个顺序。
#测量
onLayout 在每次布局导致视图 frame 变化后报告它相对父元素的 frame:
const width = signal(0);
<View onLayout={(e) => width.set(e.width)}>
<Text>{width()} points wide</Text>
</View>
事件内容为 { x, y, width, height },单位是点。要获取整个窗口的大小,读取 nsh/device 中的 windowSize(),这个访问器会跟随旋转和窗口缩放变化:
import { View } from "nsh";
import { windowSize } from "nsh/device";
<View style={{ flexDirection: windowSize().width > 600 ? "row" : "column" }} />
Tailwind 的断点(sm:、md: 等)以及 portrait: / landscape: 不用写代码就能达到同样的效果。
#安全区域
根视图覆盖整个屏幕,包括状态栏和主屏幕指示条下方的区域。把页面包在 nsh/ui/mobile 的 SafeAreaView 中,它会按安全区域的 inset 为内容加内边距;也可以用 safeArea() / useSafeAreaInsets() 读取 inset 自行处理。导航栈中的页面本身已经位于标题栏下方。
#从右到左
nsh/device 中的 I18nManager 控制整个应用的布局方向。当应用以从右到左方式布局时(系统语言为从右到左的语言,或调用了 I18nManager.forceRTL(true)):
- 行从右侧开始排列;
left和right属性(外边距、内边距、位置、边框、圆角)左右互换;start和end属性始终表示起始侧和结束侧:marginStart、marginEnd、paddingStart、paddingEnd、start、end、borderStartWidth、borderTopStartRadius等;textAlign: "auto"的文字右对齐,原生控件也会镜像。
import { Text, View } from "nsh";
import { I18nManager } from "nsh/device";
<View style={{ flexDirection: "row", gap: 8 }}>
<View style={{ marginStart: 16, borderTopStartRadius: 12, width: 44, height: 44, backgroundColor: "#54aeff" }} />
</View>
<View onPress={() => I18nManager.forceRTL(!I18nManager.isRTL)}>
<Text>{I18nManager.isRTL ? "Right to left" : "Left to right"}</Text>
</View>
I18nManager.isRTL 是响应式的,修改会立即生效,无需重启。direction 样式("inherit"、"ltr"、"rtl")可以为单个视图及其子元素覆盖方向。在 Android 上,嵌入 nsh 的应用需要在 manifest 中设置 android:supportsRtl="true",原生控件才会镜像。