指南
设备与环境
应用运行时,所处的环境会不断变化:窗口改变尺寸(旋转、分屏、Mac 窗口),用户切换到深色模式或更大的字号,键盘出现又消失。nsh 把这些都以响应式访问器的形式提供:像 signal 一样调用它,读取了它的东西(样式、文本、计算值)就会在它变化时更新。不需要添加或移除监听器。
import { mount, Text, View } from "nsh";
import { colorScheme, fontScale, I18nManager, PixelRatio, Platform, windowSize } from "nsh/device";
function Row(props: { label: string; value: string }) {
return (
<View class="flex-row py-2 border-b border-zinc-200 dark:border-zinc-700">
<Text class="flex-1 text-zinc-500 dark:text-zinc-400">{props.label}</Text>
<Text class="text-zinc-900 dark:text-zinc-100">{props.value}</Text>
</View>
);
}
function App() {
const hairline = 1 / PixelRatio.get();
return (
<View class="flex-1 bg-zinc-100 dark:bg-zinc-900 px-4 pt-6 gap-4">
<Text class="text-2xl font-bold text-zinc-900 dark:text-white">Environment</Text>
<View class="rounded-xl bg-white dark:bg-zinc-800 px-4 py-1">
<Row label="windowSize()" value={`${Math.round(windowSize().width)} × ${Math.round(windowSize().height)}`} />
<Row label="colorScheme()" value={colorScheme()} />
<Row label="fontScale()" value={fontScale().toFixed(2)} />
<Row label="Platform.OS" value={Platform.OS} />
<Row label="Platform.Version" value={String(Platform.Version)} />
<Row label="interfaceIdiom" value={Platform.constants.interfaceIdiom} />
<Row label="PixelRatio.get()" value={String(PixelRatio.get())} />
<Row label="I18nManager.isRTL" value={String(I18nManager.isRTL)} />
</View>
<Text class="text-zinc-600 dark:text-zinc-300">
{Platform.select({ ios: "Running on iOS.", android: "Running on Android.", default: "Running elsewhere." })}
</Text>
<View class="gap-1">
<View style={{ height: hairline, backgroundColor: "#8c959f" }} />
<Text class="text-xs text-zinc-500">The line above is one device pixel tall.</Text>
</View>
<View class={windowSize().width >= 600 ? "flex-row gap-3" : "gap-3"}>
<View class="flex-1 rounded-xl bg-sky-500 dark:bg-sky-700 p-4">
<Text class="text-white font-semibold">dark: classes follow the system</Text>
</View>
<View class="flex-1 rounded-xl bg-violet-500 dark:bg-violet-700 p-4">
<Text class="text-white font-semibold">A row only when the window is 600 pt or wider</Text>
</View>
</View>
<View
onPress={() => I18nManager.forceRTL(!I18nManager.isRTL)}
activeOpacity={0.6}
class="flex-row items-center gap-3 rounded-xl bg-white dark:bg-zinc-800 p-4"
>
<Text class="text-lg">⇄</Text>
<Text class="flex-1 text-zinc-900 dark:text-zinc-100">Lay out right to left: {I18nManager.isRTL ? "on" : "off"}</Text>
</View>
</View>
);
}
mount(App);| 访问器 | 模块 | 值 |
|---|---|---|
windowSize() | nsh/device | surface 的 { width, height },单位为点。 |
colorScheme() | nsh/device | "light" 或 "dark"。 |
fontScale() | nsh/device | 用户偏好的文字大小相对于默认值的比例(1 为默认)。 |
safeArea() | nsh/ui/mobile | surface 中未被系统 UI 遮挡的 inset:{ top, right, bottom, left }。 |
useSafeAreaInsets() | nsh/ui/mobile | 调用组件渲染位置(页面、模态视图)的 inset。 |
keyboard() | nsh/ui/mobile | 软键盘的 { height, duration }。 |
I18nManager.isRTL | nsh/device | 应用是否从右到左布局。 |
reduceMotion()、screenReaderEnabled() | nsh/accessibility | 见无障碍。 |
import { mount, Text, View } from "nsh";
import { colorScheme, fontScale, windowSize } from "nsh/device";
import { safeArea } from "nsh/ui/mobile";
function App() {
return (
<View style={{ flex: 1, paddingTop: safeArea().top + 16, paddingHorizontal: 16, backgroundColor: colorScheme() === "dark" ? "#0d1117" : "#ffffff" }}>
<Text style={{ color: colorScheme() === "dark" ? "#e6edf3" : "#1f2328" }}>
{Math.round(windowSize().width)} × {Math.round(windowSize().height)} pt, text at {fontScale().toFixed(2)}×
</Text>
</View>
);
}
mount(App);
#窗口尺寸与响应式布局
windowSize() 是 nsh 渲染目标 surface 的尺寸:手机上是屏幕,iPad 分屏或 Mac 上是窗口,嵌入时是宿主视图。用它来改变布局,而不只是改变尺寸:
import { View } from "nsh";
import { windowSize } from "nsh/device";
import { Image } from "nsh/image";
function Gallery() {
const columns = () => (windowSize().width >= 700 ? 4 : windowSize().width >= 500 ? 3 : 2);
return (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
<Each of={photos}>
{(p) => <Image src={p().url} style={{ width: (windowSize().width - 32 - (columns() - 1) * 8) / columns(), aspectRatio: 1 }} />}
</Each>
</View>
);
}
很多时候 flexbox、百分比和 aspectRatio 就能完成任务,根本不需要读取尺寸;见布局。使用 Tailwind 时,断点变体(sm:、md:、lg:、xl:、2xl:、max-*:、min-[…]:)和 portrait: / landscape: 同样跟随窗口:class="grid grid-cols-2 md:grid-cols-4"。
#深色模式
colorScheme() 跟随系统外观。支持深色模式最简单的方法是 Tailwind 的 dark: 变体,它在 colorScheme() 为 "dark" 时生效:
<View class="flex-1 bg-white dark:bg-zinc-900 p-4">
<Text class="text-zinc-900 dark:text-zinc-100">Follows the system appearance</Text>
</View>
使用内联样式时,在样式中读取 colorScheme();颜色较多时,先统一派生出一套调色板:
import { computed, Text, View } from "nsh";
import { colorScheme } from "nsh/device";
const palette = computed(() =>
colorScheme() === "dark"
? { background: "#0d1117", surface: "#161b22", text: "#e6edf3", muted: "#8b949e" }
: { background: "#f6f8fa", surface: "#ffffff", text: "#1f2328", muted: "#57606a" },
);
<View style={{ flex: 1, backgroundColor: palette().background }}>
<Text style={{ color: palette().text }}>Hello</Text>
</View>
nsh/ui/mobile 的组件从组件主题中取色,主题为每种颜色提供浅色和深色两个值(nsh.json 的 "theme"),所以它们也会随系统切换。要让状态栏与背景相配,见 StatusBar。
#文字大小
fontScale() 是用户的文字大小设置(iOS 的动态字体,Android 的字体大小)相对于默认值的比例。文本和输入框会自动跟随:当用户要求更大的文字时,fontSize: 16 会渲染得更大。可以针对单个元素关闭或设置上限:
属性(<Text> 和 <Input>) | 默认值 | 说明 |
|---|---|---|
allowFontScaling | true | 跟随系统文字大小。 |
maxFontSizeMultiplier | 0(无上限) | 应用于该文本的最大缩放。 |
<Text style={{ fontSize: 34, fontWeight: "700" }} maxFontSizeMultiplier={1.4}>Today</Text>
对于不会自动跟随文字的布局,读取 fontScale() 来调整,例如文字变大时把横排改为竖排:flexDirection: fontScale() > 1.3 ? "column" : "row"。
#安全区域
刘海、Home 指示条、状态栏、Android 导航栏以及 Mac Catalyst 的标题栏会遮挡 surface 的一部分。nsh 应用在所有平台上都是全屏绘制,被遮挡的部分以 inset 的形式提供。
safeArea()给出整个 surface 的 inset。useSafeAreaInsets()给出组件渲染位置的 inset:在导航页面中从标题栏下方算起,在模态视图中是模态视图自己的。可能出现在多个地方的组件应该优先用它。nsh/ui/mobile中的SafeAreaView是一个按这些 inset 添加内边距的视图(默认四边都加,或用edges={["top", "bottom"]}指定),叠加在它自己的内边距之上。
import { View } from "nsh";
import { SafeAreaView, useSafeAreaInsets } from "nsh/ui/mobile";
function Screen() {
return (
<SafeAreaView edges={["top", "left", "right"]} style={{ flex: 1, backgroundColor: "#ffffff" }}>
<Content />
<BottomBar />
</SafeAreaView>
);
}
function BottomBar() {
const insets = useSafeAreaInsets();
return <View style={{ paddingBottom: insets().bottom + 8, paddingTop: 8, flexDirection: "row" }}>…</View>;
}
在 Android 上,嵌入在工具栏下方的视图只会得到与它重叠的那部分系统栏,与 UIKit 的安全区域一致:如果它没有延伸到状态栏,顶部 inset 就为 0。
#平台
nsh/device 中的 Platform 描述应用运行的平台。它不是响应式的:这些信息在运行期间不会改变。
| 成员 | 说明 |
|---|---|
Platform.OS | "ios"、"android"、"macos"、"windows" 或 "linux"。Mac Catalyst 报告 "ios"。 |
Platform.Version | iOS 和桌面平台:版本字符串("17.5");Android:API 级别(数字)。 |
Platform.isPad | iPad(或 iPad 界面风格)。 |
Platform.isTV | 电视。 |
Platform.isMacCatalyst | Mac 界面风格的 Mac Catalyst。 |
Platform.isDesktop | macOS、Windows 或 Linux。 |
Platform.constants | { osVersion, interfaceIdiom },界面风格为 "phone"、"pad"、"mac"、"tv" 或 "desktop"。 |
Platform.select(spec) | 当前平台对应的值;见平台特定代码。 |
import { Platform } from "nsh/device";
const hapticsSupported = Platform.OS === "ios" || (Platform.OS === "android" && (Platform.Version as number) >= 30);
nsh trace 以 iOS 身份无界面运行。
#像素密度
nsh/device 中的 PixelRatio 在点与设备像素之间换算。
| 方法 | 说明 |
|---|---|
PixelRatio.get() | 每个点对应的设备像素数(大多数手机为 2 或 3)。 |
PixelRatio.getFontScale() | 与 fontScale() 相同,但不是响应式的。 |
PixelRatio.getPixelSizeForLayoutSize(points) | 把点换算为整数设备像素。 |
PixelRatio.roundToNearestPixel(points) | 把点数取整到最近的设备像素。 |
一条一个设备像素高的细线:
import { View } from "nsh";
import { PixelRatio } from "nsh/device";
const hairline = 1 / PixelRatio.get();
<View style={{ height: hairline, backgroundColor: "#d0d7de" }} />
PixelRatio 不是响应式的。窗口可能被移到另一块密度不同的屏幕上(接了外接显示器的 Mac),所以在同时读取 windowSize() 的组件中读取它,这种情况下 windowSize() 会变化。
#从右到左
当设备语言是从右到左书写的(阿拉伯语、希伯来语、波斯语等),nsh 会从右到左布局应用:横排从右侧开始,left 和 right 样式属性互换,文字右对齐,原生控件镜像显示。nsh/device 中的 I18nManager 控制这一行为:
| 成员 | 说明 |
|---|---|
I18nManager.isRTL | 应用是否从右到左布局(响应式)。 |
I18nManager.allowRTL(allow) | 跟随从右到左的语言(默认 true)。 |
I18nManager.forceRTL(force) | 无论语言如何都从右到左布局。 |
I18nManager.getConstants() | { isRTL, doLeftAndRightSwapInRTL }。 |
修改会立即生效,无需重启应用;本页的演示就在切换 forceRTL。
用逻辑属性编写两个方向都适用的布局:marginStart / marginEnd、paddingStart / paddingEnd、start / end、borderTopStartRadius 等,它们在从左到右布局中表示左和右,从右到左时则相反。left 和 right 本身也会互换。direction 样式("inherit"、"ltr"、"rtl")可以为子树覆盖方向,例如让电话号码或播放器控件保持从左到右。Tailwind 的 rtl: 和 ltr: 变体按方向生效。
在从右到左布局中,横向滚动视图和列表从最右端开始。在 Android 上,宿主应用必须在清单中声明 android:supportsRtl="true",原生控件才会镜像。
#相关内容
nsh/device:参考。- 平台特定代码:
Platform.select、平台属性与模块。 - 无障碍:屏幕阅读器与减少动态效果。
SafeAreaView与StatusBar。- Tailwind:
dark:、断点与方向变体。