组件

导航

nsh/ui/mobile 提供原生导航:由 createNavigator 创建的导航器驱动的、由多个 Screen 组成的 Stack,以及用于在所有内容之上展示内容的 Modal。在 iOS 上,栈就是 UINavigationController,带原生标题栏和侧滑返回;在 Android 上,它是带工具栏和预测性返回的页面栈。

ui-navigation.tsx 在线编辑 在新标签页打开
// Navigation: a stack of screens with header options, pushed and popped.
import { mount, signal, Text, View } from "nsh";
import { Button, createNavigator, Screen, Stack } from "nsh/ui/mobile";

type Route = { name: "home" } | { name: "detail"; id: number };

const nav = createNavigator<Route>({ name: "home" });

function Home() {
  const opened = signal(0);
  return (
    <Screen title="Inbox">
      <View style={{ flex: 1, backgroundColor: "#ffffff", padding: 16, paddingTop: 24, gap: 12 }}>
        <Text style={{ color: "#737373" }}>{`Opened ${opened()} messages`}</Text>
        <Button
          title="Open message 1"
          onPress={() => {
            opened.set(opened() + 1);
            nav.push({ name: "detail", id: 1 });
          }}
        />
        <Button title="Open message 2" variant="secondary" onPress={() => nav.push({ name: "detail", id: 2 })} />
      </View>
    </Screen>
  );
}

function Detail(props: { id: number }) {
  const starred = signal(false);
  return (
    <Screen
      title={`Message ${props.id}`}
      headerRight={
        <Text onPress={() => starred.set(!starred())} style={{ fontSize: 17 }}>
          {starred() ? "★" : "☆"}
        </Text>
      }
    >
      <View style={{ flex: 1, backgroundColor: "#ffffff", padding: 16, gap: 12 }}>
        <Text>{`The body of message ${props.id}.`}</Text>
        <Button title="Next message" onPress={() => nav.push({ name: "detail", id: props.id + 1 })} />
        <Button title="Back to the inbox" variant="secondary" onPress={() => nav.popToTop()} />
      </View>
    </Screen>
  );
}

function App() {
  return (
    <Stack navigator={nav} style={{ flex: 1 }}>
      {(route) => (route.name === "home" ? <Home /> : <Detail id={route.id} />)}
    </Stack>
  );
}

mount(App);
import { createNavigator, Modal, Screen, Stack } from "nsh/ui/mobile";

本页是 API 参考。完整的上手讲解见导航指南。

#用法

import { mount, View } from "nsh";
import { Button, createNavigator, Screen, Stack } from "nsh/ui/mobile";

type Route = { name: "home" } | { name: "detail"; id: number };

const nav = createNavigator<Route>({ name: "home" });

function Home() {
  return (
    <Screen title="Inbox">
      <View style={{ flex: 1, padding: 16 }}>
        <Button title="Open message 1" onPress={() => nav.push({ name: "detail", id: 1 })} />
      </View>
    </Screen>
  );
}

function Detail(props: { id: number }) {
  return (
    <Screen title={`Message ${props.id}`}>
      <View style={{ flex: 1, padding: 16 }}>
        <Button title="Back" variant="secondary" onPress={() => nav.pop()} />
      </View>
    </Screen>
  );
}

function App() {
  return (
    <Stack navigator={nav} style={{ flex: 1 }}>
      {(route) => (route.name === "home" ? <Home /> : <Detail id={route.id} />)}
    </Stack>
  );
}

mount(App);

#栈的工作方式

  • 导航器保存路由:由你自行决定的普通值,从栈底到栈顶排列。路由通常是一个对象,标明页面并携带参数。
  • Stack 为每个路由调用一次子函数,并把结果显示为一个页面。对同一个路由它不会再次调用该函数,因此页面在栈中期间(包括被其他页面覆盖时)会保留状态:signal、滚动位置、输入内容。
  • 当用户以原生方式离开页面(返回按钮、侧滑返回、Android 返回键)时,栈会从导航器中移除对应路由,因此 nav.routes() 始终与用户看到的一致。
  • 路由按引用(===)比较。栈中的每个路由都必须是不同的值:每次 push 一个新对象({ name: "detail", id }),不要重复使用同一个;对可能出现两次的页面,不要用字符串作为路由。
  • 如果子函数返回的不是 <Screen>,栈会用一个默认选项的页面把它包起来。要设置标题和其他标题栏选项,请返回 <Screen>。

#createNavigator

function createNavigator<R>(initial: R | R[]): Navigator<R>

创建一个导航器,其栈以 initial(一个或多个路由)开始。只需创建一次(例如在模块顶层),然后传给 <Stack navigator>。任何代码都可以导入它来进行导航。

成员说明
routes: Accessor<readonly R[]>路由,从栈底到栈顶。响应式。
push(route: R): void压入一个页面。
pop(count?: number): void弹出 count 个页面(默认 1)。至少保留一个页面。
popToTop(): void返回到第一个页面。
replace(route: R): void替换栈顶页面。
reset(routes: R[]): void替换所有页面。
remove(route: R): void移除某个路由,无论它在栈中的什么位置。
nav.push({ name: "detail", id: 7 });
nav.pop(2);
nav.reset([{ name: "home" }, { name: "detail", id: 1 }]);
const depth = () => nav.routes().length;

#Stack

<Stack navigator={nav} style={{ flex: 1 }}>
  {(route, index) => …}
</Stack>
属性类型说明
navigatorNavigator<R>保存路由的导航器。必填。
children(route: R, index: Accessor<number>) => JSX.Element渲染一个路由。返回 <Screen> 以设置标题栏选项。必填。
styleStyleProp<ViewStyle>栈的样式,通常为 { flex: 1 }。

子函数接收路由值本身,以及它在栈中位置的访问器。

#Screen

Screen 是栈中的一个页面,并配置其标题栏:

<Screen
  title="Profile"
  headerTintColor="#ffffff"
  headerBackgroundColor="#1a7f37"
  headerTitleColor="#ffffff"
  headerRight={<Text onPress={edit} style={{ color: "#ffffff", fontSize: 17 }}>Edit</Text>}
>
  <Scroll style={{ flex: 1 }}>…</Scroll>
</Screen>
属性类型默认值说明
titlestring无标题栏的标题。
headerShownbooleantrue是否显示标题栏。
headerTransparentbooleanfalse内容延伸到透明标题栏下方。
headerTintColorColorValue平台默认返回按钮和栏按钮的颜色。
headerBackgroundColorColorValue平台默认标题栏背景色。
headerTitleColorColorValue平台默认标题颜色。
headerBackVisiblebooleantrue是否显示返回按钮。
headerLeftJSX.Element无标题栏左侧的内容,替代返回按钮。
headerRightJSX.Element无标题栏右侧的内容。
headerTitleJSX.Element无替代标题的内容。
gestureEnabledbooleantrue交互式返回手势:iOS 侧滑返回、Android 跟随手指的预测性返回。无论如何,返回按钮都可用。
preventBackbooleanfalse用户不能离开该页面。见离开页面。
animation"default" | "fade" | "none""default"页面进入和离开的方式。
ios{ headerLargeTitle?: boolean; headerBackTitle?: string }无仅 iOS:随内容滚动收缩进栏内的大标题;返回到本页面的返回按钮文字。
styleStyleProp<ViewStyle>无页面样式。
childrenJSX.Element无内容。
onBackRequest() => void无用户试图离开设置了 preventBack 的页面。
onDismiss() => void无用户以原生方式离开了页面,其路由已被移除。

标题栏项(headerLeft、headerRight、headerTitle)就是普通的 JSX:它们可以读取 signal、处理点击,并在页面显示期间更新。

页面内容从不透明标题栏的下方开始。设置 headerTransparent 或 headerShown={false} 时,内容从屏幕顶部开始;可以用 SafeAreaView 或 useSafeAreaInsets() 避开标题栏和状态栏。

<Screen title="Large title" ios={{ headerLargeTitle: true }}>
  <Scroll style={{ flex: 1 }}>…</Scroll>
</Screen>

#离开页面

preventBack 让用户停留在当前页面,例如表单中有未保存的修改时。它为 true 时,侧滑返回被关闭,标题栏的返回按钮和 Android 返回键会调用 onBackRequest 而不是弹出页面。询问用户后,再由你自己弹出:

import { Input, signal } from "nsh";
import { Alert, Screen } from "nsh/ui/mobile";

function EditNote() {
  const text = signal("");
  const confirm = async () => {
    const i = await Alert.alert("Discard your changes?", undefined, [
      { text: "Keep editing", style: "cancel" },
      { text: "Discard", style: "destructive" },
    ]);
    if (i === 1) nav.pop();
  };
  return (
    <Screen title="Edit" preventBack={text().length > 0} onBackRequest={confirm}>
      <Input value={text()} onChangeText={(v) => text.set(v)} placeholder="A note" />
    </Screen>
  );
}

如果只想关闭侧滑手势而保留返回按钮,使用 gestureEnabled={false}。

Modal 在所有内容之上(包括栈的标题栏)展示内容,位于它自己的原生窗口层中。visible 为 true(默认)时显示,并且它是受控的:用户请求关闭时会调用 onRequestClose,由你把 visible 设为 false。

import { signal, Text, View } from "nsh";
import { Button, Modal, SafeAreaView } from "nsh/ui/mobile";

function Settings() {
  const open = signal(false);
  return (
    <View>
      <Button title="About" onPress={() => open.set(true)} />
      <Modal visible={open()} ios={{ presentationStyle: "pageSheet" }} onRequestClose={() => open.set(false)}>
        <SafeAreaView style={{ flex: 1, padding: 20, gap: 12 }}>
          <Text style={{ fontSize: 24, fontWeight: "700" }}>About</Text>
          <Button title="Close" onPress={() => open.set(false)} />
        </SafeAreaView>
      </Modal>
    </View>
  );
}
属性类型默认值说明
visiblebooleantrue为 true 时显示。
animationType"slide" | "fade" | "none""slide"Modal 出现的方式。
transparentbooleanfalse背景透出后面的内容,用于对话框和浮层。
dismissiblebooleanfalse用户以原生方式关闭它(iOS 下滑关闭 sheet、Android 返回键),随后调用 onRequestClose 和 onDismiss。不设置时,这些手势只是请求关闭。
ios{ presentationStyle?: "fullScreen" | "pageSheet" | "formSheet"; supportedOrientations?: (…)[] }无仅 iOS:pageSheet 和 formSheet 会让后面的内容保持可见;Modal 允许的屏幕方向(默认与呈现它的界面一致)。
android{ statusBarTranslucent?: boolean }无仅 Android:内容延伸到状态栏下方(默认从状态栏下方开始)。
styleStyleProp<ViewStyle>无Modal 的样式。
childrenJSX.Element无内容。
onRequestClose() => void无用户请求关闭(Android 返回键、iOS 下滑 sheet)。请把 visible 设为 false。
onShow() => void无Modal 已显示。
onDismiss() => void无Modal 已关闭。

Modal 会把自己的安全区边距提供给其内容,因此其中的 SafeAreaView 和 useSafeAreaInsets() 使用的是 Modal 的边距。设置 transparent 和 animationType="fade" 的 Modal 可以当作自定义对话框:

<Modal visible={open()} transparent animationType="fade" onRequestClose={close}>
  <View style={{ flex: 1, backgroundColor: "rgba(0,0,0,0.4)", alignItems: "center", justifyContent: "center", padding: 32 }}>
    <View style={{ alignSelf: "stretch", backgroundColor: "#ffffff", borderRadius: 14, padding: 20, gap: 12 }}>
      <Text style={{ fontSize: 20, fontWeight: "700" }}>A dialog</Text>
      <Button title="OK" onPress={close} />
    </View>
  </View>
</Modal>

关于该元素的更多内容见 Modal。

#平台说明

  • iOS:使用 UINavigationController,带原生标题栏、返回按钮和侧滑返回。ios={{ headerLargeTitle }} 和 headerBackTitle 仅在 iOS 上有效。在转场进行中到达的栈变更会在转场结束后应用。
  • Android:带工具栏的页面栈。系统返回键会弹出栈顶页面;在支持的 Android 版本上使用预测性返回。如需自行处理返回键,见 nsh/android 中的 BackHandler。
  • Mac Catalyst:与 iOS 相同。page sheet 和 form sheet 以与窗口同尺寸的 macOS sheet 形式呈现。

#相关内容

  • 导航指南。
  • SafeAreaView:标题栏下方和 Modal 中的安全区边距。
  • Sheet:由 nsh 绘制的底部面板,两个平台外观一致。
  • Alert:离开页面前请用户确认。