Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

Scrollbar

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, крейта nsh-base для Rust. В 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 в сигнале). Отключите собственный индикатор области прокрутки через scrollbar={false} и разместите полосу через class: поверх края области прокрутки в блоке relative, охватывающем обе, или в любом другом месте экрана.

  • Длина ползунка соответствует доле содержимого, которую показывает область просмотра, вдоль контентного блока дорожки (внутри её отступов), и не меньше minThumbLength. Его положение показывает, насколько прокручено содержимое; пока содержимое отскакивает за пределы края, ползунок остаётся у этого края.
  • Перетаскивание ползунка прокручивает содержимое на столько же, на сколько сдвигается ползунок. Нажатие на дорожку переносит середину ползунка под указатель, и перетаскивание продолжается оттуда.
  • Вертикальная полоса следует за вертикальной областью прокрутки; axis="horizontal" создаёт полосу для горизонтальной (разместите её вдоль нижнего края). Одна полоса на ось.
  • Списки работают так же: ползунок следует за полной длиной списка по мере измерения и добавления строк.

#Когда она показывается

modeПоказывается
"scrolling" (по умолчанию)Пока содержимое прокручивается, и до motion.idle после остановки; указатель на полосе, пока она показана, удерживает её.
"hover"Пока указатель находится над полосой (в том месте, где она показывается, даже когда скрыта), а также как в режиме "scrolling".
"always"Всегда.

В любом режиме полоса показывается, пока ползунок перетаскивается, и остаётся motion.idle после последней прокрутки, перетаскивания или наведения. Пока содержимое помещается в области просмотра, полоса остаётся скрытой. В скрытом состоянии у полосы opacity: 0, и она пропускает нажатия к тому, что находится под ней.

#Анимация

motion задаёт время затуханий полосы, которые выполняются нативно, как ScrollbarMotion у gpui-base:

ПолеПо умолчаниюОписание
idle2000Сколько полоса остаётся после последней прокрутки, перетаскивания или наведения, в миллисекундах.
enter0Сколько времени занимает появление: непрозрачность растёт линейно, сдвиг замедляется к концу.
exit0Сколько времени занимает затухание после окончания idle, с ускорением к концу; при затухании полоса выезжает за пределы своей дорожки.
entrance"fade"Как она появляется: "fade" на месте или "slideAndFade" от края, у которого находится (справа, снизу).
thumbHoverEntrance"fade"Как она появляется, когда указатель на скрытом ползунке показывает её ("hover").

Без длительностей полоса появляется и исчезает мгновенно. Затухание, развёрнутое на полпути, сохраняет скорость: оно длится столько, сколько составляет оставшаяся доля его длительности. Полоса "always" никогда не анимируется, как и любая полоса, пока пользователь просит уменьшить движение. Дорожка обрезает то, что сдвигается. Собственная анимация ползунка, например ширина, которая растёт при hover:, относится к его стилю (transition-all у thumbClass).

#Свойства

Свойство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" следует за указателем мыши или тачпада (на десктопах и в вебе); на сенсорном экране полоса показывается, пока содержимое прокручивается, а её скрытая дорожка пропускает касания.