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

元素

Toast

Toast 是在應用程式上方短暫顯示的簡短訊息:「已複製」、「草稿已刪除 · 復原」、「無法傳送」。 nsh/base 以三個無樣式的部分提供其行為,與 GPUI Kit 的 gpui-base 相同:

el-toast.tsx
el-toast.tsxLive
import { mount, Overlay, signal, Text, View } from "nsh";
import { Toast, type ToastEntry, ToastManager, ToastStack } from "nsh/base";
import { safeArea } from "nsh";
import { button, buttonText, desktop, Log, size, ui, wide } from "./palette";

/** What the demo's toasts show. */
type Content = { title: string; description?: string; undo?: boolean };

function App() {
  const manager = new ToastManager();
  const shown = signal<ToastEntry[]>([]);
  const expanded = signal(false);
  const events = signal<string[]>([]);
  const log = (e: string) => events.set([e, ...events()].slice(0, 6));
  const sync = () => shown.set(manager.visible(3));
  // The lifecycle advances on the app's timer, paused while the stack is expanded.
  setInterval(() => {
    const a = manager.advance(expanded());
    for (const id of a.presented) log(`presented · ${id}`);
    for (const id of a.ending) log(`ending · ${id}`);
    for (const r of a.removed) log(`removed · ${r.id}`);
    if (a.changed) sync();
  }, 50);
  let count = 0;
  const push = (content: Content) => {
    const id = `toast-${++count}`;
    manager.push(id, content, { timeout: 5000 });
    log(`push · ${id}`);
    sync();
  };
  const dismiss = (id: string) => {
    manager.dismiss(id);
    sync();
  };
  /** A button of the demo. */
  const action = (label: string, onPress: () => void) => (
    <View onPress={onPress} {...button()}>
      <Text style={buttonText()}>{label}</Text>
    </View>
  );

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>toast</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Show a few; rest the pointer on the stack to expand it and stop their time" : "Show a few; touch the stack to expand it, swipe one right to dismiss it"}
      </Text>
      <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
        {action("Copy the link", () => push({ title: "Copied", description: "The link is on the clipboard" }))}
        {action("Delete the draft", () => push({ title: "Draft deleted", undo: true }))}
        {action("Dismiss all", () => {
          manager.dismissAll();
          sync();
        })}
      </View>
      <Log title="ToastManager.advance · onExpandedChange" lines={events()} minHeight={130} />

      <Overlay style={{ pointerEvents: "box-none", left: 0, right: 0, bottom: 0, padding: 16, paddingBottom: safeArea().bottom + 16, alignItems: wide() ? "flex-end" : "center" }}>
        <ToastStack
          placement={wide() ? "bottomRight" : "bottomCenter"}
          style={{ width: 340, maxWidth: "100%" }}
          onExpandedChange={({ expanded: open }) => {
            expanded.set(open);
            log(`onExpandedChange · ${open}`);
          }}
          onCancel={() => {
            const newest = shown().filter((t) => t.status !== "ending").at(-1);
            if (newest) dismiss(newest.id);
          }}
        >
          <Each of={shown()} key={(t: ToastEntry) => t.id}>
            {(t) => {
              const content = () => t().value as Content;
              return (
                <Toast
                  transitionStatus={t().status}
                  accessibilityLabel={content().title}
                  onDismissRequest={() => dismiss(t().id)}
                  class="transition-all duration-200 ease-out"
                  startingClass="opacity-0 translate-y-4"
                  endingClass="opacity-0 scale-95"
                  swipingClass="transition-none"
                  style={{ ...ui().card, flexDirection: "row", alignItems: "center", gap: 12, padding: 14, borderRadius: 12 }}
                >
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: 14, fontWeight: "600", color: ui().text }}>{content().title}</Text>
                    <If when={content().description}>
                      <Text style={{ fontSize: 13, color: ui().muted }}>{content().description}</Text>
                    </If>
                  </View>
                  <If when={content().undo}>
                    <View onPress={() => dismiss(t().id)} {...button("primary", { size: "sm" })}>
                      <Text style={buttonText("primary")}>Undo</Text>
                    </View>
                  </If>
                </Toast>
              );
            }}
          </Each>
        </ToastStack>
      </Overlay>
    </View>
  );
}

mount(App);
  • ToastManager 依序保存各個 toast 及其生命週期:每個 toast 先是 "starting",動畫時長過後變為 "present",在存在且未暫停時倒數其逾時,並在退出動畫的時長內維持 "ending",之後才被移除。
  • ToastStack 測量其 toast 並從某個邊緣開始排版,最新的最靠近該邊緣:收合時為重疊的層,展開時(指標位於其上,或手指按在其上)為清單,並以彈簧動畫移動。
  • Toast 是每個 toast 的根:alert 角色、供應用程式樣式使用的 transitionStatus、要求關閉它的滑動手勢,以及在其顯示時由螢幕閱讀器朗讀其標籤。

外觀與內容由應用程式決定,從任何地方顯示 toast 的便利性也是:應用程式在這些元件之上建構自己的 toast(...),如 gallery 所做的(examples/gallery/toast.tsx),GPUI Kit 的通知也是建構在 gpui-base 的 toast 之上。

use std::time::Duration;

use nsh_core::{time::now, prelude::*};

use nsh_base::{
    Anchor, ToastManager, ToastMotion, ToastOptions, ToastStackState, theme, toast, toast_stack,
};

fn toasts() -> impl IntoElement {
    let manager = stored(ToastManager::<u64, SharedString>::new(ToastMotion::sonner()));
    let shown = signal(Vec::new());
    let state = ToastStackState::new();
    let sync = move || {
        shown.set(manager.with(|m| {
            m.visible(3)
                .map(|(id, t, s)| (*id, t.clone(), s))
                .collect::<Vec<_>>()
        }))
    };
    // Advance the lifecycle while toasts are mounted, paused while expanded.
    set_interval(Duration::from_millis(50), move || {
        if manager.with(|m| m.advance(now(), state.is_expanded()).changed) {
            sync();
        }
    });
    manager.with(|m| {
        m.push(
            1,
            "Copied".into(),
            ToastOptions::timeout(Some(Duration::from_secs(5))),
            now(),
        )
    });
    sync();
    toast_stack()
        .placement(Anchor::BottomCenter)
        .state(state)
        .w(px(340.0))
        .child(each(
            shown,
            |(id, _, _)| *id,
            move |item, _| {
                toast()
                    .transition_status(move || item.get().2)
                    .accessibility_label(move || Some(item.get().1))
                    .on_dismiss_request(move || {
                        manager.with(|m| m.dismiss(&item.get().0, now()));
                    })
                    .rounded_xl()
                    .px_4()
                    .py_3()
                    .bg(move || theme().card)
                    .shadow_lg()
                    .transition_all()
                    .duration(200.0)
                    .starting_class("opacity-0 translate-y-4")
                    .ending_class("opacity-0 scale-95")
                    .child(text(move || item.get().1))
            },
        ))
}

Toast、ToastStack 與 ToastManager 來自 nsh/base。在 Rust 中,它們是 nsh_base 的 toast、toast_stack 與 ToastManager。

#ToastManager

管理器是 headless 的:不渲染任何內容,也不執行計時器。應用程式推入 toast,連同其狀態渲染 visible(limit) 回傳的那些 toast,並在任何 toast 已掛載時以計時器呼叫 advance,在堆疊展開時傳入 paused。在 Rust 中它是針對應用程式的 id 與值的 ToastManager<I, T>,每個呼叫各有一個 Instant;在 TS 中它是針對字串 id 與任何 JSON 值的類別,使用引擎的時鐘(包含測試的手動時鐘)。

  • push(id, value, options) 新增最新的 toast,並取代具有相同 id 的 toast(回傳其值);options.timeout 為其顯示時間,單位為毫秒(無:持續到被關閉為止)。
  • dismiss(id) 開始某個 toast 的退出;dismissAll() 開始所有 toast 的退出,並回傳其 id。
  • advance(paused) 推進生命週期並回報有何變化:presented、ending(其逾時已到)、removed(連同其值),以及 changed。
  • visible(limit) 是最新的 limit 個未在退出的 toast,加上正在退出的 toast,最舊的在前;entries()(Rust 為 iter())是全部;get(id)、len()、isEmpty()。

ToastMotion 保存兩者的 token:duration(400 ms:進場的期限與堆疊的彈簧回應時間)、exitDuration(200 ms)、collapsedPeek(14)、expandedGap(14)、collapsedScaleStep(0.05)與 collapsedVisible(3),預設值皆取自 Sonner(Rust 中為 ToastMotion::sonner();TS 中省略的欄位會保留這些值)。

#ToastStack

堆疊的子元素即為其 toast,最舊的在前。從其 placement 邊緣(預設為 "topRight";底部錨點向上排版)開始,收合的 toast 彼此相距 collapsedPeek 重疊,每多一個就窄 collapsedScaleStep,且只會顯示 collapsedVisible;展開時則為彼此相距 expandedGap 的清單。toast 的到來與離開,以及展開,都以臨界阻尼彈簧移動。當指標在其上或手指按在其上時即為展開(onExpandedChange({ expanded });Rust 為 ToastStackState,由 is_expanded() 讀取),因此應用程式會在使用者閱讀或觸碰 toast 時暫停逾時。

堆疊由應用程式擺放:放在邊緣的 Overlay 中,位於安全區域內。其 class 與 style 決定其寬度。當它持有 toast 時,會接收 Escape 與 Android 返回鍵(onCancel;closeOnEscape={false} 則將它們留給應用程式的其餘部分):關閉最新的 toast。

#Toast

Toast 是應用程式內容外圍的 alert 角色。其 transitionStatus 來自管理器,其狀態 class 透過其 class 跟隨它:"starting" 時,其第一個影格帶有 startingClass,使其 transition class 從它開始動畫(Tailwind 的 starting:,CSS 的 @starting-style);"ending" 時帶有 endingClass,持續退出動畫的時長;指標滑動它時帶有 swipingClass(transition-none 使其停留在手指下)。

手指或滑鼠朝 swipeDirection(預設為 "right";null 會關閉滑動)拖曳時會移動它,但要等指標移動之後才開始,因此其按鈕仍然是按鈕;放開時若超過 swipeThreshold 點(50)或是快速甩動,它會繼續移出並要求被關閉(onDismissRequest),未達則會回到原位。螢幕閱讀器會在其顯示時朗讀其 accessibilityLabel。

#Toast 屬性

屬性Rust型別預設值說明
transitionStatus.transition_status(..)"starting" | "present" | "ending""starting"其生命週期階段。
startingClass, endingClass, swipingClass.starting_class(..), …string無其在第一個影格、退出中、被滑動時的 class。
swipeDirection.swipe_direction(..)"right" | "left" | "up" | "down" | null"right"其被滑開的方向。
swipeThreshold.swipe_threshold(..)number50滑動多遠之後,放開才會要求關閉它。
accessibilityLabel.accessibility_label(..)string無螢幕閱讀器在其顯示時朗讀的內容。
onDismissRequest.on_dismiss_request(..)() => void無使用者將其滑開。

#ToastStack 屬性

屬性Rust型別預設值說明
motion.motion(..)ToastMotionSonner 的預設值其動態 token。
placement.placement(..)"topLeft" | "topCenter" | "topRight" | "bottomLeft" | "bottomCenter" | "bottomRight" | "leftCenter" | "rightCenter""topRight"其排版所從出發的邊緣。
closeOnEscape.close_on_escape(..)booleantrue持有 toast 時,接收 Escape 與返回。
onExpandedChange.on_expanded_change(..), .state(..)(e: { expanded: boolean }) => void無互動使其展開或收合。
onCancel.on_cancel(..)() => void無Escape 或返回:關閉最新的 toast。