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

元素

Scrollbar

捲軸讓捲動檢視或清單擁有應用程式自己風格的捲軸:一個指標移上去會變寬的細長膠囊形滑塊、符合品牌色調的軌道、位於固定標題旁或沿著兩個窗格的捲軸。將其設為應用程式的主題後,每個捲動檢視與清單都會繪製一個捲軸來取代平台的指示器(內建捲軸);由應用程式自行放置時,它會指定所要移動的捲動檢視,並可放在應用程式指定的任何位置。它自行負責其行為:滑塊的長度與位置會隨內容、可視區域與偏移量而變(清單增長或視窗調整大小時亦然);拖曳滑塊會捲動內容,在軌道上按一下會讓滑塊的中央移到該處;它會在內容捲動時、指標位於其上時,或始終顯示,並以原生方式淡入淡出。外觀與時間由應用程式決定:Scrollbar 本身沒有顏色、尺寸或圓角,在應用程式給定時間之前,淡入淡出不耗費任何時間。

el-scrollbar.tsx
el-scrollbar.tsxLive
import { mount, Scroll, signal, Text, View, type ScrollRef } from "nsh";
import { Scrollbar, type ScrollbarMode } from "nsh/base";
import { desktop, Panel, Segmented, size, Split, ui } from "./palette";

// A list of messages in a card whose own indicator is off, with the app's
// bar over its right edge, timed as gpui-component's: a 6-point pill in the
// palette's muted tone that widens to 8 under the pointer (`hover:`) and
// while dragged (`active:`), fading in over 300 ms and out over 500 ms, 2 s
// after the last scroll (`motion`); pointing at its hidden thumb in the
// `hover` mode slides it in. The inspector switches when it shows and
// follows the offset the list reports.
const MESSAGES = Array.from({ length: 60 }, (_, i) => ({
  from: ["Ada", "Grace", "Linus", "Ken", "Barbara"][i % 5],
  text: ["Shipped the build", "Review when you can", "Tests are green", "Moved the meeting", "Draft is ready"][i % 5],
  at: `${9 + Math.floor(i / 6)}:${String((i * 7) % 60).padStart(2, "0")}`,
}));

function App() {
  const mode = signal<ScrollbarMode>("scrolling");
  const offset = signal(0);
  let inbox: ScrollRef | undefined;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>scrollbar</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Scroll the list, drag the thumb or click the track" : "Scroll the list, or drag the thumb"}
      </Text>
      <Split
        gap={14}
        grow
        inspector={
          <Panel title="scrollbar">
            <Segmented
              label="mode"
              options={["scrolling", "hover", "always"] as const}
              value={mode()}
              onChange={(v) => mode.set(v)}
            />
            <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
              {`offset ${Math.round(offset())}`}
            </Text>
          </Panel>
        }
      >
        <View style={{ ...ui().card, flex: 1, minHeight: 320, borderRadius: 16, overflow: "hidden" }}>
          <Scroll
            ref={inbox}
            scrollbar={false}
            style={{ flex: 1 }}
            onScroll={(e) => offset.set(e.offset.y)}
          >
            {MESSAGES.map((m) => (
              <View
                class={desktop ? "flex-row gap-3 px-3 py-2.5" : "flex-row gap-3 px-4 py-3"}
                style={{ borderBottomWidth: 1, borderColor: ui().line }}
              >
                <View style={{ flex: 1, gap: 2 }}>
                  <Text style={{ fontSize: size.label, fontWeight: "500", color: ui().text }}>{m.from}</Text>
                  <Text style={{ fontSize: size.detail, color: ui().muted }}>{m.text}</Text>
                </View>
                <Text style={{ fontSize: size.detail, color: ui().muted, fontVariant: ["tabular-nums"] }}>{m.at}</Text>
              </View>
            ))}
          </Scroll>
          <Scrollbar
            scroll={inbox}
            mode={mode()}
            motion={{ idle: 2000, enter: 300, exit: 500, entrance: "fade", thumbHoverEntrance: "slideAndFade" }}
            accessibilityLabel="Messages"
            class="absolute inset-y-0 right-0 w-4 items-end p-1"
            thumbClass="w-1.5 rounded-full opacity-60 transition-all duration-300 hover:w-2 hover:opacity-90 active:w-2 active:opacity-100"
            thumbStyle={{ backgroundColor: ui().muted }}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{ScrollbarEntrance, ScrollbarMode, ScrollbarMotion, scrollbar, theme};
use nsh_core::prelude::*;

fn inbox(rows: impl IntoElement) -> impl IntoElement {
    let list = element_ref();
    // In over 300 ms and out over 500 ms, 2 s after the last scroll; pointing
    // at the hidden thumb slides it in from the edge.
    let mut motion = ScrollbarMotion::default();
    motion.enter = 300.0;
    motion.exit = 500.0;
    motion.thumb_hover_entrance = ScrollbarEntrance::SlideAndFade;
    view()
        .relative()
        .flex_1()
        // The scroll view's own indicator gives way to the app's bar.
        .child(scroll().element_ref(&list).flex_1().scrollbar(false).child(rows))
        .child(
            scrollbar()
                .scroll(list)
                .mode(ScrollbarMode::Hover)
                .motion(motion)
                .accessibility_label("Messages")
                // The track over the right edge; the thumb right-aligned in it.
                .absolute()
                .top_0()
                .bottom_0()
                .right_0()
                .w(px(16.0))
                .items_end()
                .p(px(4.0))
                .thumb_style(|s| {
                    s.w(px(6.0))
                        .rounded_full()
                        .bg(move || theme().muted_foreground)
                        .opacity(0.6)
                        .group_hover(|s| s.w(px(8.0)).opacity(0.9))
                }),
        )
}

Scrollbar 來自 nsh/base,也就是 Rust 中的 nsh-base crate。在 Rust 中,scrollbar() 會以每個屬性一個設定方法(.scroll(..)、.thumb_style(..))以及捲軸本身的具型別樣式方法來建立捲軸;其 scroll 接受 ElementRef(或 ScrollbarTarget)。

#內建捲軸

每個捲動檢視與清單都可以繪製 Scrollbar 來取代平台的指示器,無需另外放置捲軸:Scrollbar.setTheme 只需在應用程式啟動時設定一次,就會為每個 scrollbar 為 true 的檢視賦予應用程式的捲軸外觀。每個檢視在其可視區域上方各有自己的捲軸,內容捲動時它仍留在原處(與黏性檢視一樣,於主執行緒上處理),並繪製在內容之上;它的跟隨、捲動與淡入淡出方式與已放置的 Scrollbar 相同。

use nsh_base::{ScrollbarLook, ScrollbarMode, ScrollbarTheme, set_scrollbar_theme};

fn use_app_scrollbars() {
    let mut vertical = ScrollbarLook::default();
    vertical.class = Some("absolute inset-y-0 right-0 w-4 items-end p-1".into());
    vertical.thumb_class = Some("w-1.5 rounded-full bg-muted-foreground opacity-60 hover:w-2".into());
    let mut horizontal = ScrollbarLook::default();
    horizontal.class = Some("absolute inset-x-0 bottom-0 h-4 items-end p-1".into());
    horizontal.thumb_class = Some("h-1.5 rounded-full bg-muted-foreground opacity-60 hover:h-2".into());
    let mut theme = ScrollbarTheme::default();
    theme.mode = ScrollbarMode::Hover;
    theme.motion.enter = 300.0;
    theme.motion.exit = 500.0;
    theme.vertical = Some(vertical);
    theme.horizontal = Some(horizontal);
    set_scrollbar_theme(Some(theme));
}

ScrollbarTheme 包含對所有檢視都相同的捲軸屬性,以及每個軸向的外觀:

欄位型別預設值說明
mode"scrolling" | "hover" | "always""scrolling"捲軸何時顯示:何時顯示。
motionScrollbarMotion立即切換,idle 2000捲軸如何顯示與隱藏:動態效果。
minThumbLengthnumber48滑塊的最短長度。
vertical、horizontalScrollbarLook無垂直檢視與水平檢視的捲軸外觀:軌道使用 class 與 style,並放置於檢視的可視區域中;此外還有 thumbClass 與 thumbStyle;以及 accessibilityLabel。若檢視的某個軸向沒有設定外觀,則保留平台的指示器。

之後的 setTheme 會重新設定捲軸樣式,而 Scrollbar.setTheme(null) 會把平台的指示器還給各檢視。單一檢視可以透過自己的 scrollbar 屬性(<Scroll scrollbar={{ mode: "always", vertical: { … } }}>)採用專屬主題,無論應用程式是否已設定主題皆然;scrollbar={false} 會讓檢視不顯示捲軸。在其父層可視區域中排版的清單(viewport="parent")會與該檢視及其捲軸一起捲動。多行的 Input 也會在文字上方取得其垂直外觀的捲軸,並像文字檢視自己的指示器一樣捲動文字。

#它所移動的捲動檢視

scroll 指定捲軸所跟隨的捲動檢視或清單,也就是它的 ref。捲軸會在其顯示於畫面後找到它,因此 ref 可以由同一次渲染中較後面的元素設定;請把捲軸寫在其捲動檢視之後(或將 ref 保存在 signal 中)。使用 scrollbar={false} 關閉捲動檢視自己的指示器,並使用 class 放置捲軸:可置於捲動檢視邊緣之上、包住兩者的 relative 方塊中,或畫面上的其他任何位置。

  • 滑塊的長度等於可視區域所顯示內容的比例,沿著軌道的內容方塊(其內距之內)計算,且不短於 minThumbLength。它的位置代表內容已捲動的距離;當內容彈跳超出任一端時,滑塊會停在該端。
  • 拖曳滑塊時,內容捲動的量等於滑塊移動的量。在軌道上按一下會讓滑塊的中央移到指標下方,之後的拖曳會從該處繼續。
  • 垂直捲軸跟隨垂直捲動檢視;axis="horizontal" 會為水平捲動檢視建立捲軸(請將它沿底部邊緣放置)。每個軸向一個捲軸。
  • 清單的運作方式相同:隨著列被測量與新增,滑塊會跟隨清單的整體長度。

#何時顯示

mode顯示時機
"scrolling"(預設)內容捲動時,並在停止後持續到 motion.idle 為止;捲軸顯示期間若指標位於其上,則會持續顯示。
"hover"指標位於捲軸上時(即使處於隱藏狀態,它所在之處仍會顯示),以及與 "scrolling" 相同的時機。
"always"始終顯示。

在所有模式下,拖曳滑塊時都會顯示,並在最後一次捲動、拖曳或懸停之後維持 motion.idle。當內容能容納於可視區域時,捲軸保持隱藏。隱藏時,捲軸的狀態為 opacity: 0,並讓按下的操作穿透到其下方的元素。

#動態效果

motion 控制捲軸淡入淡出的時間,這些效果以原生方式執行,與 gpui-base 的 ScrollbarMotion 相同:

欄位預設值說明
idle2000捲軸在最後一次捲動、拖曳或懸停後停留的時間,單位為毫秒。
enter0出現所需的時間:不透明度線性上升,滑動則採用緩出。
exit0在 idle 結束後淡出所需的時間,採用緩入;淡出時會滑出其軌道。
entrance"fade"進場方式:原地 "fade",或從其所在的邊緣(右側、底部)"slideAndFade"。
thumbHoverEntrance"fade"指標位於隱藏的滑塊上而使其顯示時("hover")的進場方式。

若未設定時間長度,捲軸會立即出現與消失。淡入淡出在中途反轉時會保持其速度:它會執行剩餘尚未完成的那一部分時間。"always" 捲軸從不播放動畫,在使用者要求減少動態效果時,任何捲軸也都不會。軌道會裁切滑動的部分。滑塊自身的動態效果,例如在 hover: 時變寬的寬度,則由其樣式決定(thumbClass 上的 transition-all)。

#屬性

屬性Rust型別預設值說明
scroll.scroll(..)YNode無捲軸所跟隨並移動的捲動檢視或清單(其 ref)。
axis.axis(..)"horizontal" | "vertical""vertical"捲動檢視的軸向:軌道沿著它延伸。在建立捲軸時讀取。
mode.mode(..)"scrolling" | "hover" | "always""scrolling"捲軸何時顯示。
motion.motion(..)ScrollbarMotion立即切換,idle 2000捲軸如何顯示與隱藏:動態效果。
minThumbLength.min_thumb_length(..)number48內容很長時滑塊的最短長度,使其保持容易抓取。
thumbClass、thumbStyle.thumb_style(|s| ..)string、StyleProp<ViewStyle>無滑塊。
accessibilityLabel.accessibility_label(..)string無螢幕閱讀器對捲軸的朗讀內容。
class、style樣式方法string、StyleProp<ViewStyle>無捲軸:它的軌道,位於應用程式放置之處。

#組成部分

部分位於樣式設定方式
軌道應用程式放置之處class、style
滑塊軌道的內容方塊thumbClass、thumbStyle

捲軸會沿著軸向調整滑塊的大小並移動它;滑塊的粗細及其在軌道上橫向的位置則由應用程式決定(在軌道上設定 w-1.5 與 items-end,會在右側放置一個 6 點寬的滑塊)。軌道的內距可讓滑塊遠離其兩端與兩側。

各部分會顯示的狀態:

  • 軌道:指標位於其下時為 hover:;它是一個 group,因此滑塊會從它取得 group-hover:(例如指標靠近時會變寬的滑塊)。
  • 滑塊:指標位於其下時為 hover:,被拖曳時為 active:。

捲軸的可見度就是它的不透明度:請將顏色的透明度設定在軌道的背景或滑塊上。會變寬的滑塊使用 transition-all(它絕不會延遲滑塊的位置,位置由捲軸獨立於滑塊樣式之外設定),而 motion-reduce: 會讓要求減少動態效果的使用者看到它保持靜止。

#無障礙

  • 螢幕閱讀器所捲動的仍是捲動檢視:它們以自己的手勢移動它,並朗讀其內容。
  • 對於能夠存取捲軸的螢幕閱讀器,捲軸以 accessibilityLabel 作為標籤。

關於標籤、角色與動作的一般說明,請參閱無障礙。

#平台說明

  • 每個平台都會繪製應用程式所設定的樣式,滑塊則跟隨各平台為捲動檢視所回報的偏移量。拖曳滑塊與在軌道上按壓,可使用滑鼠、手寫筆或手指操作。
  • "hover" 會跟隨滑鼠或觸控板的指標(桌面平台與網頁);在觸控螢幕上,捲軸會在內容捲動時顯示,其隱藏的軌道則會讓觸控穿透。