本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

Sheet

Sheet 是位於螢幕邊緣的面板,下方覆蓋一層遮罩,蓋住畫面其餘部分:篩選器、分享面板、側邊抽屜皆屬此類。按下遮罩、按 Escape、Android 返回鍵,以及 VoiceOver 的退出手勢,都可以關閉它。它會顯示在視窗內所有內容的最上層,在每種裝置上表現一致,本身不繪製任何內容:面板與遮罩都由應用程式自行提供。

el-sheet.tsx
el-sheet.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Sheet } from "nsh/base";
import { button, buttonText, desktop, Inspector, Log, Panel, size, Stage, ui, wide } from "./palette";

type Side = "bottom" | "top" | "left" | "right";

/** The panel's corners and size across, by the edge it is on. */
function panel(side: Side) {
  const r = 20;
  const across = side === "left" || side === "right" ? { width: wide() ? 320 : 280 } : {};
  const corners = {
    bottom: { borderTopLeftRadius: r, borderTopRightRadius: r, paddingBottom: 32 },
    top: { borderBottomLeftRadius: r, borderBottomRightRadius: r, paddingTop: 32 },
    left: { borderTopRightRadius: r, borderBottomRightRadius: r },
    right: { borderTopLeftRadius: r, borderBottomLeftRadius: r },
  }[side];
  return { ...ui().card, padding: 20, gap: 12, ...across, ...corners };
}

function App() {
  const open = signal(false);
  const side = signal<Side>("bottom");
  const events = signal<string[]>([]);
  const log = (e: string) => events.set([e, ...events()].slice(0, 6));
  const show = (s: Side) => {
    side.set(s);
    open.set(true);
    log(`open · side ${s}`);
  };
  /** A button that opens the sheet at side `s`. */
  const opener = (s: Side) => (
    <View onPress={() => show(s)} {...button("secondary", { on: () => s === side() })}>
      <Text style={buttonText("secondary", { on: () => s === side() })}>side="{s}"</Text>
    </View>
  );
  const changes = () => <Log title="onOpenChange({ open, reason }) · onCancel" lines={events()} minHeight={130} />;

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>sheet</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Open a side; click the backdrop or press Escape to close it" : "Open a side; tap the backdrop or Android's back to close it"}
      </Text>

      {() =>
        wide() ? (
          <View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
            {/* Each button at the edge its sheet comes from. */}
            <Stage width={360} gap={12}>
              <View style={{ alignItems: "center" }}>{opener("top")}</View>
              <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
                {opener("left")}
                {opener("right")}
              </View>
              <View style={{ alignItems: "center" }}>{opener("bottom")}</View>
            </Stage>
            <Inspector>
              <Panel title="open · side">
                <Text style={{ fontSize: 13, color: ui().text }}>
                  {open() ? "open" : "closed"} · {side()}
                </Text>
              </Panel>
              {changes()}
            </Inspector>
          </View>
        ) : (
          <>
            <View style={{ flex: 1, minHeight: 200, alignItems: "center", justifyContent: "center" }}>
              <View style={{ width: 240, gap: 8 }}>
                <Each of={["top", "left", "right", "bottom"] as Side[]}>{(s) => opener(s())}</Each>
              </View>
            </View>
            {changes()}
          </>
        )
      }

      <Sheet
        open={open()}
        closeOnSwipe
        side={side()}
        onOpenChange={({ open: next, reason }) => {
          open.set(next);
          log(`onOpenChange · ${next ? "opened" : "closed"} · ${reason}`);
        }}
        onCancel={() => log("onCancel")}
        popupStyle={panel(side())}
        backdropStyle={{ backgroundColor: "rgba(0,0,0,0.45)" }}
      >
        <Text style={{ fontSize: 17, fontWeight: "700", color: ui().text }}>Filters</Text>
        <Text style={{ fontSize: 14, color: ui().muted }}>
          A panel at the {side()} edge. {desktop ? "The backdrop and Escape close it." : "The backdrop and Android's back close it."}
        </Text>
        <View
          onPress={() => {
            open.set(false);
            log("Done pressed");
          }}
          {...button("primary", { align: "flex-end" })}
        >
          <Text style={buttonText("primary")}>Done</Text>
        </View>
      </Sheet>
    </View>
  );
}

mount(App);
use nsh_base::{SheetEvent, sheet};
use nsh_core::prelude::*;

fn filters(open: Signal<bool>) -> impl IntoElement {
    sheet()
        .open(move || Some(open.get()))
        .on_open_change(move |e: &SheetEvent| {
            if let SheetEvent::OpenChange(next, _) = e {
                open.set(*next);
            }
        })
        .child(
            view()
                .gap_3()
                .p_6()
                .rounded_t(px(16.0))
                .bg(white())
                .child(text("Filters").font_weight(FontWeight::SEMIBOLD))
                .child(view().on_press(move || open.set(false)).child(text("Done"))),
        )
}

Sheet 來自 nsh/base。其內容在每次開啟時重新產生。side 會將它放在螢幕的 "bottom"(預設)或 "top",寬度與螢幕相同;或放在 "left" 或 "right",高度與螢幕相同;橫跨方向的尺寸由面板決定,取決於其內容或 popupClass。

在進場過程中,內容建立或增長時,滑動動畫會跟隨面板實際量測到的尺寸。延遲載入的內容與非同步的尺寸更新共用同一個動畫進度;面板最終會停在其當前尺寸。

#Open state

未設定 open 時,Sheet 會自行維護狀態,初始值為 defaultOpen。設定 open 後,狀態由應用程式掌控:請在 onOpenChange({ open, reason }) 中變更,它會回報每一次關閉請求及其原因("backdropPress"、"cancel"、"swipe")。 無論是誰開啟或關閉,onOpen 與 onClose 都會隨之觸發。

closeOnEscape={false} 會讓 Sheet 在按下 Escape 與返回鍵時保持開啟,closeOnBackdropPress={false} 則針對按下遮罩的情況; onCancel 仍然會收到這些事件,以便先行詢問。

#Dragging a bottom sheet

設定 closeOnSwipe,可讓底部面板跟隨向下拖曳。遮罩會隨拖曳距離而淡出;放開時若超過面板高度的 30%,或向下速度達每秒 800 點,就會以原因 "swipe" 請求關閉。 較短的拖曳會平滑地回彈。受控的擁有者可以在關閉請求之後讓 open 維持為 true,使面板回到原位。 啟用減少動態效果時,放開後以及程式化的開啟/關閉會立即完成;直接拖曳仍會跟隨手指。

面板會貼合其內容,包括較短的選單;若要限制較高的內容,請使用 popupStyle 或子元件的高度。 把手由應用程式自行繪製,可以是面板內的任何 view。在 iOS 上,內部的垂直 Scroll 或 List 會先消耗拖曳,直到捲動到頂端,並在同一次手勢中將剩餘的向下移動交給面板。 在 Android、桌面平台與 Web 上,拖曳把手或捲動區域之外的內容時,使用各平台原生的平移支援;內部捲動區域則維持其平台一般的手勢仲裁方式。將 closeOnSwipe 設為 false 會讓面板保持不動,並保留已掛載的內容與捲動位置。其他邊緣則維持原有行為。

#Native sheets

Sheet 由 NSH 繪製在應用程式的視窗中,樣式由應用程式決定。若需要平台自身呈現的 Sheet(例如 iOS 上的 page sheet),請使用帶有 presentation="pageSheet" 的 Dialog。

#Props

PropRustTypeDefaultDescription
open.open(..)boolean無開啟狀態,由應用程式控制。
defaultOpen.default_open(..)booleanfalse初始即為開啟,不含 open。
side.side(..)"top" | "bottom" | "left" | "right""bottom"所在的邊緣。
closeOnEscape.close_on_escape(..)booleantrue按 Escape 與 Android 返回鍵會關閉它。
closeOnBackdropPress.close_on_backdrop_press(..)booleantrue按下遮罩會關閉它。
closeOnSwipe.close_on_swipe(..)booleanfalse底部面板跟隨向下拖曳,並在放開時達到門檻即關閉。
popupClass, popupStyle.popup_class(..), .popup_style(..)string, StyleProp<ViewStyle>無面板的外觀。
backdropClass, backdropStyle.backdrop_class(..), .backdrop_style(..)string, StyleProp<ViewStyle>無遮罩的外觀,例如半透明顏色。
children.child(..)JSX.Element無內容。

#Events

EventRustDescription
onOpenChange.on_open_change(|e| ..)關閉請求,並附上原因:"backdropPress"、"cancel" 或 "swipe"。
onOpen.on_open(|| ..)已進場:全部內容皆已就位。
onClose.on_close(|| ..)已離場:全部內容皆已消失。應用程式主動關閉時也會觸發。
onCancel.on_cancel(|| ..)使用者嘗試關閉它(按 Escape 或返回鍵、按下遮罩、已啟用的滑動手勢),無論最終是否關閉。

在 Web 上,使用 dialogs: "viewport" 嵌入時,Sheet 及其遮罩會呈現在宿主頁面之上,包括同源的 iframe。跨來源的 frame 請使用 hostPlayerFrame。