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

Документация

Прокрутка

Прокручиваемое представление (scroll() в Rust, <Scroll> в TSX) — это собственное представление прокрутки платформы (UIScrollView на iOS, ScrollView на Android): fling-жесты, отскоки, индикатор прокрутки и pull to refresh нативны, как и привязка к точкам остановки (snapping). Ваш код узнаёт о прокрутке через события и может привязать смещение к анимируемому значению для эффектов, которые следуют за пальцем кадр за кадром.

guide-scrolling.tsx
guide-scrolling.tsxLive
import { mount, Scroll, type ScrollRef, signal, Text, View } from "nsh";
import { createAnimatedValue } from "nsh";
import { button, buttonText, desktop, size, tint, ui, wide } from "./palette";

// Tints of the info, violet, danger, warn and up colours under the text colour.
const hues = ["info", "violet", "down", "warn", "up", "faint"] as const;
const hue = (i: number) => tint(ui()[hues[i % hues.length]], 0.28);

/** Pages as wide as the scroll view, with a page indicator. */
function Pager() {
  const width = signal(340);
  const page = signal(0);
  return (
    <View style={{ gap: 8 }}>
      <Scroll
        horizontal
        pagingEnabled
        scrollbar={false}
        style={{ height: 110, borderRadius: 12 }}
        onLayout={({ bounds }) => width.set(bounds.size.width)}
        onMomentumEnd={({ offset }) => page.set(Math.round(offset.x / width()))}
        onScrollEnd={({ offset }) => page.set(Math.round(offset.x / width()))}
      >
        <View style={{ flexDirection: "row" }}>
          <Each of={[0, 1, 2, 3]}>
            {(i) => (
              <View style={{ width: width(), height: 110, backgroundColor: hue(i()), alignItems: "center", justifyContent: "center" }}>
                <Text style={{ color: ui().text, fontSize: 24, fontWeight: "700" }}>Page {i() + 1}</Text>
              </View>
            )}
          </Each>
        </View>
      </Scroll>
      <View style={{ flexDirection: "row", justifyContent: "center", gap: 6 }}>
        <Each of={[0, 1, 2, 3]}>
          {(i) => <View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: page() === i() ? ui().accent : ui().border }} />}
        </Each>
      </View>
    </View>
  );
}

/** Cards that snap: one stop every card plus gap. */
function Cards() {
  return (
    <Scroll horizontal scrollbar={false} snapToInterval={152} decelerationRate="fast" style={{ height: 90 }}>
      <View style={{ flexDirection: "row", gap: 12, paddingHorizontal: 4 }}>
        <Each of={Array.from({ length: 12 }, (_, i) => i)}>
          {(i) => (
            <View
              style={{
                width: 140,
                height: 90,
                borderRadius: 14,
                backgroundColor: hue(i()),
                boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
                alignItems: "center",
                justifyContent: "center",
              }}
            >
              <Text style={{ color: ui().text, fontWeight: "700" }}>Card {i() + 1}</Text>
            </View>
          )}
        </Each>
      </View>
    </Scroll>
  );
}

function App() {
  // The vertical offset, written natively in the frame it changes.
  const offset = createAnimatedValue(0);
  const y = signal(0);
  const refreshing = signal(false);
  const refreshed = signal(0);
  let scroll!: ScrollRef;
  const refresh = () => {
    refreshing.set(true);
    setTimeout(() => {
      refreshed.update((n) => n + 1);
      refreshing.set(false);
    }, 1200);
  };
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, paddingTop: 24 }}>
      {/* A bar that fades in and shrinks its title as the page scrolls: no JS per frame. */}
      <View
        style={{
          height: 48,
          paddingHorizontal: 16,
          justifyContent: "center",
          backgroundColor: offset.interpolate({ inputRange: [0, 80], outputRange: [ui().page, ui().card.backgroundColor] }),
          borderBottomWidth: 1,
          borderBottomColor: ui().line,
        }}
      >
        <Text
          style={{
            fontSize: 22,
            fontWeight: "700",
            color: ui().text,
            transformOrigin: "left center",
            scale: offset.interpolate({ inputRange: [0, 80], outputRange: [1, 0.8] }),
          }}
        >
          Scrolling
        </Text>
      </View>
      <Scroll
        ref={scroll}
        style={{ flex: 1 }}
        scrollValue={offset}
        scrollEventThrottle={100}
        onScroll={(e) => y.set(Math.round(e.offset.y))}
        refreshing={refreshing()}
        onRefresh={refresh}
        keyboardDismissMode="on-drag"
      >
        <View style={{ padding: 16, gap: 18, maxWidth: wide() ? 720 : undefined }}>
          <Text style={{ color: ui().muted }}>
            {desktop ? `Scroll with the wheel or the trackpad. onScroll: y = ${y()}` : `Pull down to refresh (${refreshed()}×). onScroll: y = ${y()}`}
          </Text>
          <Text style={{ fontWeight: "600", color: ui().text }}>pagingEnabled</Text>
          <Pager />
          <Text style={{ fontWeight: "600", color: ui().text }}>snapToInterval</Text>
          <Cards />
          <View style={{ gap: desktop ? 8 : 18 }}>
            <Each of={Array.from({ length: 24 }, (_, i) => i + 1)}>
              {(n) => (
                <View
                  style={
                    desktop
                      ? { ...ui().row, height: size.row, justifyContent: "center", paddingHorizontal: 12, borderRadius: 8 }
                      : { ...ui().row, padding: 14, borderRadius: 12 }
                  }
                >
                  <Text style={{ color: ui().text, fontSize: desktop ? size.label : undefined }}>Row {n()}</Text>
                </View>
              )}
            </Each>
          </View>
          <View
            onPress={() => scroll.scrollTo({ y: 0 })}
            {...button("primary")}
          >
            <Text style={buttonText("primary")}>Back to the top (ref.scrollTo)</Text>
          </View>
        </View>
      </Scroll>
    </View>
  );
}

mount(App);

Используйте прокручиваемое представление для содержимого известного и умеренного размера: страницы настроек, формы, статьи, ряда чипов. Для длинных или неограниченных данных используйте список: он переиспользует строки и принимает те же свойства прокрутки.

#Прокручиваемое представление

Прокручиваемое представление — это окно просмотра (viewport), а его дочерние элементы — содержимое. Размер окну задаёт родитель (обычно flex: 1, .flex_1()), а содержимое раскладывается по ширине окна (вертикальная прокрутка) или по высоте (горизонтальная) и имеет ту длину, которая ему нужна.

#[component]
pub fn App() -> impl IntoElement {
    scroll().flex_1().bg(rgb(0xf6f8fa)).child(
        view().p_4().gap_3().children((1..=50).map(|n| {
            view().p_4().rounded(px(10.0)).bg(white()).child(text(format!("Row {n}")))
        })),
    )
}

Дочернее представление выше — это контейнер содержимого прокручиваемого представления; в TSX contentStyle (или contentClass) создаёт его за вас, оборачивая дочерние элементы в представление с этим стилем, которое прокручивается вместе с ними. flexGrow: 1 (.flex_grow(1.0)) делает содержимое не ниже окна просмотра, поэтому короткое содержимое можно центрировать, а длинное по-прежнему прокручивается:

scroll().flex_1().child(
    view().flex_grow(1.0).justify_center().p_4().child(
        when(move || messages.with(|m| !m.is_empty()), move || {
            each(messages, |m| m.id, |m, _| text(move || m.with(|m| m.text.clone())))
        })
        .otherwise(|| text("No messages yet")),
    ),
)

Горизонтальному прокручиваемому представлению нужны horizontal (.horizontal()) и строка в качестве дочернего элемента:

scroll().horizontal().scrollbar(false).child(
    view().flex_row().gap_2().px_4().children(["Today", "This week", "This month", "This year"].map(|label| {
        text(label).px(px(14.0)).py_2().rounded(px(16.0)).overflow_hidden().bg(white())
    })),
)

При письме справа налево (см. Устройство и окружение) горизонтальное прокручиваемое представление начинается с правого края и сохраняет расстояние до него.

Прокручиваемые представления перетаскиваются пальцами. На десктопе (macOS, Windows, Linux) и в браузере мышь прокручивает так же, как в десктопных приложениях: колесом (Shift + колесо прокручивает по горизонтали), тачпадом, клавиатурой и индикатором прокрутки, который появляется при движении мыши над представлением и перетаскивается как полоса прокрутки; перетаскивание мышью достаётся жестам панорамирования приложения, респондерам и выделению текста. Сенсорные экраны на десктопах перетаскивают прокручиваемые представления так же, как пальцы. Горизонтальные представления сохраняют там индикатор даже при scrollbar={false}, так как именно с его помощью их двигает мышь без горизонтального колеса.

#Свойства поведения

ПропRustТипПо умолчаниюОписание
horizontal.horizontal()booleanfalseПрокрутка в сторону.
scrollable.scrollable(..)booleantrueПользователь может прокручивать содержимое. Прокрутка из кода продолжает работать.
bounces.bounces(..)booleantrueПрокрутка за края (overscroll).
scrollbar.scrollbar(..)booleantrueПоказывается индикатор прокрутки. На десктопе и в десктопных браузерах горизонтальное представление сохраняет его: это полоса прокрутки для мыши.
contentInset.content_inset(EdgeInsets { .. })Partial<Edges>Пространство вокруг содержимого, в которое можно прокрутить.
contentOffset.content_offset(point(..)){ x?, y? }Начальная позиция.
decelerationRate.deceleration(rate)"normal" | "fast" | numberзначение платформыКак быстро замедляется fling: "normal", "fast" или сохраняемая за миллисекунду скорость (0.998 — обычное замедление, 0.99 — быстрое; 0 — значение платформы по умолчанию).

#Постраничная прокрутка и snapping

Точка остановки выбирается нативно в момент, когда палец отрывается от экрана, по смещению в этот момент, прогнозируемому скольжению и скорости; затем представление плавно скользит к выбранной точке без участия вашего кода.

ПропRustТипОписание
pagingEnabled.paging(true)booleanОстановки кратны размеру окна просмотра.
snapToInterval.snap_interval(px(..))numberОстановки кратны этой длине (0: выключено).
snapToOffsets.snap_offsets(vec![..])number[]Остановки на этих смещениях.
snapToAlignment.snap_alignment(SnapAlignment::Center)"start" | "center" | "end"Вместе с snapToInterval: какая точка элемента выравнивается по окну просмотра.
disableIntervalMomentum.disable_interval_momentum(true)booleanFling сдвигает не более чем на одну остановку.

Пейджер: страницы шириной с прокручиваемое представление и индикатор, обновляемый по окончании скольжения.

const COLORS: [u32; 4] = [0x0969da, 0x8250df, 0xbf3989, 0x1a7f37];

pub fn pager() -> impl IntoElement {
    let width = signal(0.0_f32);
    let page = signal(0_usize);
    let settle = move |e: &Point<Points>| {
        let width = width.get_untracked();
        if width > 0.0 {
            page.set((e.x.0 / width).round() as usize);
        }
    };
    let pages = COLORS.iter().enumerate().map(|(i, &color)| {
        view()
            .w(move || px(width.get()))
            .h(px(160.0))
            .bg(rgb(color))
            .items_center()
            .justify_center()
            .child(text(format!("Page {}", i + 1)).text_color(white()).text_2xl().font_weight(FontWeight::BOLD))
    });
    let dots = (0..COLORS.len()).map(|i| {
        view()
            .size_2()
            .rounded(px(4.0))
            .bg(move || if page.get() == i { rgb(0x0969da) } else { rgb(0xd0d7de) })
    });
    view()
        .gap_2()
        .p_4()
        .child(
            scroll()
                .horizontal()
                .paging(true)
                .scrollbar(false)
                .h(px(160.0))
                .rounded(px(12.0))
                .on_layout(move |frame: &Bounds<Points>| width.set(frame.size.width.0))
                .on_scroll_end(settle)
                .child(view().flex_row().children(pages)),
        )
        .child(view().flex_row().justify_center().gap(px(6.0)).children(dots))
}

Карусель карточек, которые привязываются к центру, по одной карточке на fling:

scroll()
    .horizontal()
    .snap_interval(px(232.0))          // card width (220) + gap (12)
    .snap_alignment(SnapAlignment::Center)
    .deceleration(0.99)
    .disable_interval_momentum(true)
    .scrollbar(false)
    .child(view().flex_row().gap_3().px_4().children(cards))

Списки принимают те же свойства snapping, поэтому для длинной карусели они подходят лучше.

#События прокрутки

ПропRustДанныеКогда
onScroll.on_scroll(..)ScrollEventСмещение изменилось. Не чаще одного раза за кадр; scrollEventThrottle увеличивает промежутки между событиями.
scrollEventThrottle.event_throttle(ms)numberМинимальное число миллисекунд между событиями onScroll.
onScrollDragStart.on_scroll_drag_start(..){ offset }Пользователь начал перетаскивание.
onScrollDragEnd.on_scroll_drag_end(..){ offset }Пользователь отпустил палец.
onMomentumStart.on_momentum_start(..){ offset }Началось скольжение после fling.
onMomentumEnd.on_momentum_end(..){ offset }Скольжение закончилось (в том числе когда его перехватывает новое перетаскивание).
onScrollEnd.on_scroll_end(..){ offset }Прокрутка пользователем остановилась: после скольжения или в конце перетаскивания без него.

ScrollEvent содержит смещение (offset, Point; то же в Rust), размер содержимого, с учётом масштаба (contentSize, Size; content_size), окно просмотра (size) и zoomScale (zoom_scale). Остальные события содержат смещение: { offset } с Point в TSX, Point<Points> в Rust.

let at_bottom = signal(false);

scroll()
    .flex_1()
    .event_throttle(100.0)
    .on_scroll(move |e: &ScrollEvent| {
        at_bottom.set(e.offset.y + e.size.height >= e.content_size.height - px(20.0))
    })
    .child(content)

#Эффекты, связанные с прокруткой: scrollValue

scrollValue (.scroll_value(&value)) принимает анимируемое значение и нативно записывает в него смещение прокрутки (по главной оси) в том же кадре, в котором движется содержимое. Привяжите это значение или производные от него узлы, созданные с помощью interpolate, diffClamp (diff_clamp) или арифметики, к стилям.

#[component]
pub fn App() -> impl IntoElement {
    let offset = animated_value(0.0);
    view()
        .flex_1()
        .child(
            view()
                .h(px(56.0))
                .justify_center()
                .px_4()
                // Transparent at the top, white after 80 points.
                .bind_background(&offset.interpolate_colors(&[0.0, 80.0], &[rgba(0xffffff00), rgba(0xffffffff)]))
                .child(
                    text("Inbox")
                        .text_size(px(22.0))
                        .font_weight(FontWeight::BOLD)
                        .origin_left()
                        .bind_scale(&offset.interpolate(&[0.0, 80.0], &[1.0, 0.8])),
                ),
        )
        .child(scroll().flex_1().scroll_value(&offset).child(view().p_4().gap_2()))
}

Заголовок, который уезжает при прокрутке содержимого вверх и возвращается, как только прокрутка идёт вниз, — это diffClamp от смещения:

let offset = animated_value(0.0);
let header = offset.diff_clamp(0.0, 56.0);

view()
    .flex_1()
    .child(scroll().flex_1().scroll_value(&offset).child(view().pt(px(56.0))))
    .child(view().absolute().top_0().left_0().right_0().h(px(56.0)).bind_translate_y(&header.mul(-1.0)))

По умолчанию интерполяции ограничиваются (clamp) за пределами входного диапазона, поэтому при оттягивании выше верхнего края (отрицательное смещение) эффект остаётся в конечном состоянии; передайте extrapolate: "extend" (interpolate_with(.., Interpolation::extrapolate(Extrapolate::Extend)) в Rust), чтобы получить растягивающийся заголовок, который увеличивается при оттягивании.

#Pull to refresh

refreshing и onRefresh (.refreshing(..) и .on_refresh(..)) добавляют стандартный для платформы элемент pull-to-refresh. Элемент отображается, пока refreshing равно true; устанавливайте его в onRefresh и сбрасывайте, когда работа завершена.

let refreshing = signal(false);
let reload = move || {
    refreshing.set(true);
    spawn(async move {
        if let Ok(response) = fetch("https://api.example.com/items").await {
            items.set(parse_items(&response.text()));
        }
        refreshing.set(false);
    });
};

scroll().flex_1().refreshing(refreshing).on_refresh(reload).child(content)

На iOS этот элемент — UIRefreshControl; в Mac-режиме Mac Catalyst NSH показывает собственный индикатор с тем же поведением. Списки принимают те же свойства. На десктопе и в браузере оттягивание — это жест сенсорного экрана (или трекпада macOS, который оттягивает выше верхнего края), поскольку мышь прокручивает без перетаскивания; предложите там другой способ обновления, например кнопку или сочетание клавиш.

#Собственный индикатор

Внешний вид оттягивания выбирает приложение. refreshIndicator={{ height }} сообщает, что приложение рисует свой индикатор, height — высота в пунктах: стандартный индикатор платформы скрывается, обновление запускается, когда оттягивание достигает height, а во время обновления над содержимым сохраняется height для индикатора. pullValue передаёт в анимируемое значение, насколько содержимое оттянуто выше верхнего края: 0 в покое, растёт по мере оттягивания и равно height во время обновления. Рисуйте индикатор поверх верхней части прокручиваемого представления и интерполируйте его стили от этого значения: они следуют за пальцем в UI-потоке без JS на каждом кадре.

let pull = animated_value(0.0);

view()
    .flex_1()
    .child(
        scroll()
            .flex_1()
            .refreshing(refreshing)
            .on_refresh(reload)
            .pull_value(&pull)
            .refresh_indicator(Some(px(72.0)))
            .child(content),
    )
    .child(
        view()
            .pointer_events(PointerEvents::None)
            .absolute()
            .top_0()
            .left_0()
            .right_0()
            .h(px(72.0))
            .items_center()
            .justify_center()
            .bind_opacity(&pull.interpolate(&[0.0, 36.0], &[0.0, 1.0]))
            .bind_rotate(&pull.interpolate(&[36.0, 72.0], &[0.0, 180.0]))
            .child(arrow()),
    )

Держите height в пределах половины окна просмотра: за верхним краем содержимое сопротивляется пальцу тем сильнее, чем дальше оно оттянуто, и слишком большая высота никогда не достигается. На iOS собственный индикатор приложения заменяет UIRefreshControl на оттягивание, которое NSH определяет сам, как в Mac-режиме.

#Масштабирование

Прокручиваемое представление (списки сохраняют масштаб 1) масштабируется, когда максимальный масштаб больше минимального: щипок масштабирует содержимое, а при увеличении оно прокручивается в обе стороны.

ПропRustТипПо умолчаниюОписание
minimumZoomScale.zoom_range(min, max)number1Наименьший масштаб.
maximumZoomScale.zoom_range(min, max)number1Наибольший масштаб; если он больше минимального, щипок масштабирует.
zoomScale.zoom(scale)numberМасштабирует до этого значения при изменении свойства; в остальных случаях масштаб пользователя сохраняется.
bouncesZoom.bounces_zoom(..)booleantrueЩипок может выходить за пределы и возвращается пружинным эффектом.
zoomable.zoomable(..)booleantrueПользователь может масштабировать щипком.
centerContent.center_content(..)booleanfalseСодержимое меньше окна просмотра (при уменьшении) центрируется.

Событие прокрутки сообщает масштаб и размер содержимого с учётом масштаба. Из кода zoom_to(scale, center, animated) / zoomTo({ scale, x, y, animated }) масштабирует так, чтобы точка содержимого (в немасштабированных пунктах) оказалась в середине окна просмотра; масштабирование двойным касанием — это обработчик нажатия, который её вызывает:

pub fn photo() -> impl IntoElement {
    let photo = element_ref();
    let scale = signal(1.0_f32);
    scroll()
        .flex_1()
        .bg(black())
        .zoom_range(1.0, 4.0)
        .center_content(true)
        .on_scroll(move |e: &ScrollEvent| scale.set(e.zoom_scale))
        .element_ref(&photo)
        .child(
            image("https://picsum.photos/id/1015/1200/800")
                .w(px(390.0))
                .h(px(260.0))
                .on_press(move || photo.zoom_to(if scale.get_untracked() > 1.5 { 1.0 } else { 3.0 }, None, true)),
        )
}

Текст и canvas внутри масштабируемого прокручиваемого представления перерисовываются в новом разрешении, когда масштабирование заканчивается, поэтому на iOS они остаются чёткими. На Android NSH масштабирует с помощью собственного представления прокрутки, которое масштабирует вокруг фокальной точки щипка, панорамирует любым числом пальцев, поддерживает fling и резиновое растяжение у краёв и пределов масштаба. Android начинает щипок, когда пальцы находятся примерно в 27 мм друг от друга, как это делает системный детектор жестов.

#Прокрутка из кода

Передайте прокручиваемому представлению ref (element_ref() и .element_ref(&scroller) в Rust, ref в TSX) и вызывайте его методы. Команды выполняются после остальных изменений в том же обновлении, поэтому прокрутка к только что добавленному содержимому работает.

RustTSXОписание
scroll_to(point, animated)scrollTo({ x?, y?, animated? })Прокручивает к смещению. В TSX по умолчанию с анимацией.
zoom_to(scale, center, animated)zoomTo({ scale, x?, y?, animated? })Масштабируемые прокручиваемые представления: масштабирует до scale (с ограничением пределами) вокруг точки содержимого или середины.
let scroller = element_ref();

scroll().flex_1().element_ref(&scroller).child(content)
view().on_press(move || scroller.scroll_to(point(px(0.0), px(0.0)), true))

Списки добавляют scrollToIndex, scrollToOffset и scrollToEnd; см. Списки.

#Прокручиваемые представления и клавиатура

NSH сам держит поле ввода в фокусе видимым: когда появляется клавиатура, прокручиваемое представление вокруг поля получает нижний отступ высотой с клавиатуру и прокручивает поле в область видимости. Это происходит нативно, без кода в вашем приложении. Используйте env(keyboard-inset-height) как нижний отступ (Содержимое вне прокручиваемых представлений) для содержимого вне прокручиваемых представлений, например панели сообщений, закреплённой внизу.

Два свойства определяют, как прокрутка и касания взаимодействуют с клавиатурой:

ПропRustТипПо умолчаниюОписание
keyboardDismissMode.keyboard_dismiss_mode(KeyboardDismissMode::OnDrag)"none" | "on-drag" | "interactive""none"Скрывает ли клавиатуру перетаскивание содержимого. "interactive" позволяет клавиатуре следовать за пальцем вниз на iOS; на Android ведёт себя как "on-drag".
keyboardShouldPersistTaps.keyboard_taps(KeyboardTaps::Handled)"never" | "always" | "handled""never"Что делает касание, пока клавиатура открыта.

keyboardShouldPersistTaps:

  • "never": касание вне поля ввода в фокусе только скрывает клавиатуру, а кнопки получают следующее касание. Пользователь касается один раз, чтобы закрыть клавиатуру, и ещё раз, чтобы нажать.
  • "handled": касание того, что его обрабатывает (кнопки), проходит, и клавиатура остаётся; касание пустого места скрывает её. Обычно это правильный выбор для форм.
  • "always": клавиатура остаётся открытой при любых касаниях.

Оба поведения определяются нативно в момент касания или перетаскивания, не дожидаясь вашего кода. Чтобы скрыть клавиатуру из кода, вызовите dismiss_keyboard() в Rust или Keyboard.dismiss() из nsh в TSX.

#Вложенные прокручиваемые представления

Горизонтальное прокручиваемое представление внутри вертикального работает как ожидается: каждое обрабатывает перетаскивания вдоль своей оси. Два прокручиваемых представления с одной осью тоже можно вкладывать (внутреннее прокручивается первым). Респондеры, которым нужен палец, например холст для рисования внутри прокручиваемого представления, не отдают его окружающим прокручиваемым представлениям: .keep_touches(true) в Rust, true, возвращаемое из onResponderGrant в TSX; см. Касания и жесты.

#См. также

  • Scroll: справочник по элементу, все свойства и события.
  • Списки: виртуализированные списки с теми же свойствами прокрутки.
  • Анимация: анимируемые значения, interpolate и diffClamp для эффектов, связанных с прокруткой.
  • Формы и клавиатура: поля ввода, фокус и клавиатура.