Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

Documentação

Rolagem

A scroll view (scroll() em Rust, <Scroll> em TSX) é a scroll view própria da plataforma (UIScrollView no iOS, uma ScrollView no Android): flings, bounces, o indicador de rolagem e o pull to refresh são nativos, e o snapping também. Seu código fica sabendo da rolagem por meio de eventos e pode vincular o offset a um valor animado para criar efeitos que acompanham o dedo quadro a quadro.

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);

Use uma scroll view para conteúdo de tamanho conhecido e moderado: uma página de configurações, um formulário, um artigo, uma linha de chips. Para dados longos ou ilimitados, use uma lista, que recicla linhas e aceita as mesmas props de rolagem.

#Uma scroll view

Uma scroll view é a viewport; seus filhos são o conteúdo. A viewport precisa receber um tamanho do pai (normalmente flex: 1, .flex_1()), e o conteúdo é disposto na largura da viewport (vertical) ou na altura (horizontal) e tão longo quanto precisar.

#[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}")))
        })),
    )
}

A view filha acima é o contêiner de conteúdo da scroll view; em TSX, contentStyle (ou contentClass) cria um para você, envolvendo os filhos em uma view com esse estilo, que rola junto com eles. flexGrow: 1 (.flex_grow(1.0)) faz o conteúdo ter pelo menos a altura da viewport, de modo que um conteúdo curto possa ser centralizado e um conteúdo longo continue rolando:

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")),
    ),
)

Uma scroll view horizontal recebe horizontal (.horizontal()) e uma linha como filha:

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())
    })),
)

Em idiomas da direita para a esquerda (veja Dispositivo e ambiente), uma scroll view horizontal começa na extremidade direita e mantém a distância a partir dela.

Os dedos arrastam as scroll views. No desktop (macOS, Windows, Linux) e no navegador, o mouse rola como nos apps de desktop: com a roda (Shift + roda rola na horizontal), o touchpad, o teclado e o indicador de rolagem, que aparece enquanto o mouse se move sobre a view e pode ser arrastado como uma barra de rolagem; os arrastos do mouse vão para os pans, responders e seleção de texto do app. As telas sensíveis ao toque em desktops arrastam as scroll views como os dedos fazem. As views horizontais mantêm o indicador nesses ambientes mesmo com scrollbar={false}, pois é por ele que um mouse sem roda horizontal as move.

#Props de comportamento

Prop Rust Tipo Padrão Descrição
horizontal .horizontal() boolean false Rola na lateral.
scrollable .scrollable(..) boolean true O usuário pode rolá-la. A rolagem por código continua funcionando.
bounces .bounces(..) boolean true Overscroll além das bordas.
scrollbar .scrollbar(..) boolean true O indicador de rolagem é exibido. No desktop e em navegadores de desktop, uma view horizontal o mantém: ele é a barra de rolagem do mouse.
contentInset .content_inset(EdgeInsets { .. }) Partial<Edges> Espaço ao redor do conteúdo, no qual é possível rolar.
contentOffset .content_offset(point(..)) { x?, y? } Onde ela começa.
decelerationRate .deceleration(rate) "normal" | "fast" | number o da plataforma Com que rapidez um fling desacelera: "normal", "fast" ou a velocidade mantida por milissegundo (0.998 é normal, 0.99 é rápido; 0 é o padrão da plataforma).

#Paginação e snapping

O snapping é decidido de forma nativa quando o dedo é levantado, a partir do offset no momento da soltura, do deslize projetado e da velocidade; a scroll view então desliza até a parada escolhida, sem nenhum código seu envolvido.

Prop Rust Tipo Descrição
pagingEnabled .paging(true) boolean Para em múltiplos do tamanho da viewport.
snapToInterval .snap_interval(px(..)) number Para em múltiplos deste comprimento (0: desligado).
snapToOffsets .snap_offsets(vec![..]) number[] Para nestes offsets.
snapToAlignment .snap_alignment(SnapAlignment::Center) "start" | "center" | "end" Com snapToInterval: qual ponto de um item se alinha com a viewport.
disableIntervalMomentum .disable_interval_momentum(true) boolean Um fling avança no máximo uma parada.

Um pager: páginas com a mesma largura da scroll view e um indicador atualizado quando o deslize termina.

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))
}

Um carrossel de cards que se encaixam no centro, um card por 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))

As listas aceitam as mesmas props de snapping, o que as torna a melhor escolha para um carrossel longo.

#Eventos de rolagem

Prop Rust Payload Quando
onScroll .on_scroll(..) ScrollEvent O offset mudou. No máximo uma vez por quadro; scrollEventThrottle espaça mais os eventos.
scrollEventThrottle .event_throttle(ms) number Mínimo de milissegundos entre eventos onScroll.
onScrollDragStart .on_scroll_drag_start(..) { offset } O usuário começou a arrastar.
onScrollDragEnd .on_scroll_drag_end(..) { offset } O usuário levantou o dedo.
onMomentumStart .on_momentum_start(..) { offset } O deslize após um fling começou.
onMomentumEnd .on_momentum_end(..) { offset } O deslize terminou (também quando um novo arrasto o interrompe).
onScrollEnd .on_scroll_end(..) { offset } Uma rolagem do usuário parou: após o deslize, ou ao fim de um arrasto sem deslize.

ScrollEvent traz o offset (offset, um Point; o mesmo em Rust), o tamanho do conteúdo, com zoom (contentSize, um Size; content_size), a viewport (size) e zoomScale (zoom_scale). Os outros eventos trazem o offset: { offset } com um Point em TSX, um Point<Points> em 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)

#Efeitos vinculados à rolagem: scrollValue

scrollValue (.scroll_value(&value)) recebe um valor animado e grava nele o offset de rolagem (ao longo do eixo principal) de forma nativa, no mesmo quadro em que o conteúdo se move. Vincule esse valor, ou nós derivados dele com interpolate, diffClamp (diff_clamp) ou aritmética, a estilos.

#[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()))
}

Um cabeçalho que desliza para fora enquanto o conteúdo rola para cima e volta assim que ele rola para baixo é diffClamp do offset:

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)))

Por padrão, as interpolações limitam o valor fora do intervalo de entrada, então puxar além do topo (um offset negativo) mantém o efeito no seu extremo; passe extrapolate: "extend" (interpolate_with(.., Interpolation::extrapolate(Extrapolate::Extend)) em Rust) para um cabeçalho elástico que cresce conforme você puxa.

#Pull to refresh

refreshing e onRefresh (.refreshing(..) e .on_refresh(..)) adicionam o controle de pull-to-refresh da plataforma. O controle é exibido enquanto refreshing for verdadeiro; defina-o em onRefresh e limpe-o quando o trabalho terminar.

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)

No iOS, o controle é o UIRefreshControl; no idioma Mac do Mac Catalyst, o NSH exibe seu próprio spinner com o mesmo comportamento. As listas aceitam as mesmas props. No desktop e no navegador, o puxar é um gesto de tela sensível ao toque (ou de trackpad do macOS, que puxa além do topo), já que o mouse rola sem arrastar; ofereça outra forma de atualizar nesses ambientes, como um botão ou um atalho de teclado.

#Um indicador próprio

A aparência do puxar fica a critério do app. refreshIndicator={{ height }} indica que o app desenha seu próprio indicador, height pontos de altura: o spinner da plataforma fica oculto, um puxar atualiza assim que alcança height, e durante a atualização height ficam acima do conteúdo para o indicador. pullValue alimenta um valor animado com o quanto o conteúdo foi puxado além do topo: 0 em repouso, crescendo com o puxar, e height enquanto atualiza. Desenhe o indicador sobre o topo da scroll view e interpole seus estilos a partir do valor: eles acompanham o dedo na thread de UI, sem JS por quadro.

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()),
    )

Mantenha height dentro de metade da viewport: além do topo, o conteúdo resiste mais ao dedo quanto mais longe ele vai, e uma altura excessiva nunca é alcançada. No iOS, um indicador próprio do app substitui o UIRefreshControl pelo puxar que o próprio NSH detecta, como no idioma Mac.

#Zoom

Uma scroll view (as listas mantêm a escala 1) dá zoom quando sua escala máxima de zoom é maior que a mínima: o gesto de pinça escala o conteúdo e, com zoom aplicado, ele rola nos dois sentidos.

Prop Rust Tipo Padrão Descrição
minimumZoomScale .zoom_range(min, max) number 1 O menor zoom.
maximumZoomScale .zoom_range(min, max) number 1 O maior zoom; acima do mínimo, a pinça dá zoom.
zoomScale .zoom(scale) number Aplica zoom nesta escala quando a prop muda; caso contrário, o zoom do usuário é mantido.
bouncesZoom .bounces_zoom(..) boolean true A pinça pode ultrapassar os limites e retorna com efeito de mola.
zoomable .zoomable(..) boolean true O usuário pode fazer pinça para dar zoom.
centerContent .center_content(..) boolean false Conteúdo menor que a viewport (com zoom reduzido) fica centralizado.

O evento de rolagem informa a escala de zoom e o tamanho do conteúdo com zoom. Pelo código, zoom_to(scale, center, animated) / zoomTo({ scale, x, y, animated }) aplica zoom de modo que o ponto do conteúdo (em pontos sem zoom) fique no meio da viewport; o toque duplo para dar zoom é um handler de press que o chama:

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)),
        )
}

Textos e canvases dentro de uma scroll view com zoom são desenhados novamente na nova resolução quando o zoom termina, de modo que permanecem nítidos no iOS. No Android, o NSH aplica o zoom com uma scroll view própria, que escala em torno do ponto focal da pinça, faz pan com qualquer número de dedos, faz fling e efeito elástico nas bordas e nos limites de zoom. O Android inicia uma pinça quando os dedos estão a cerca de 27 mm de distância, como faz o detector de gestos do sistema.

#Rolando a partir do código

Dê à scroll view uma ref (element_ref() e .element_ref(&scroller) em Rust, um ref em TSX) e chame seus métodos. Os comandos são executados depois do restante das alterações na mesma atualização, então rolar até um conteúdo que você acabou de adicionar funciona.

Rust TSX Descrição
scroll_to(point, animated) scrollTo({ x?, y?, animated? }) Rola até um offset. Animado por padrão em TSX.
zoom_to(scale, center, animated) zoomTo({ scale, x?, y?, animated? }) Scroll views com zoom: aplica zoom em scale (limitado aos limites), em torno do ponto do conteúdo ou do meio.
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))

As listas adicionam scrollToIndex, scrollToOffset e scrollToEnd; veja Listas.

#Scroll views e o teclado

O NSH mantém um input em foco visível por conta própria: quando o teclado aparece, a scroll view ao redor do input recebe um inset inferior com a altura do teclado e rola o input até ficar visível. Isso acontece de forma nativa, sem código no seu app. Use env(keyboard-inset-height) como padding inferior (Conteúdo fora das scroll views) para o conteúdo fora de scroll views, como uma barra de mensagem fixada na parte inferior.

Duas props decidem como a rolagem e os toques interagem com o teclado:

Prop Rust Tipo Padrão Descrição
keyboardDismissMode .keyboard_dismiss_mode(KeyboardDismissMode::OnDrag) "none" | "on-drag" | "interactive" "none" Define se arrastar o conteúdo oculta o teclado. "interactive" faz o teclado acompanhar o dedo para baixo no iOS; no Android, comporta-se como "on-drag".
keyboardShouldPersistTaps .keyboard_taps(KeyboardTaps::Handled) "never" | "always" | "handled" "never" O que um toque faz enquanto o teclado está aberto.

keyboardShouldPersistTaps:

  • "never": um toque fora do input em foco apenas oculta o teclado, e os botões recebem o toque seguinte. O usuário toca uma vez para dispensar e outra para pressionar.
  • "handled": um toque em algo que o trata (um botão) passa adiante e o teclado permanece; um toque em um espaço vazio o oculta. Geralmente a escolha certa para formulários.
  • "always": o teclado permanece aberto durante todos os toques.

Ambas são decididas de forma nativa quando o toque ou o arrasto acontece, sem esperar pelo seu código. Para ocultar o teclado pelo código, chame dismiss_keyboard() em Rust ou Keyboard.dismiss() de nsh em TSX.

#Scroll views aninhadas

Uma scroll view horizontal dentro de uma vertical funciona como esperado: cada uma recebe os arrastos ao longo do seu próprio eixo. Duas scroll views no mesmo eixo também podem ser aninhadas (a interna rola primeiro). Responders que precisam do dedo, como um canvas de desenho dentro de uma scroll view, o mantêm longe das scroll views ao redor: .keep_touches(true) em Rust, true retornado de onResponderGrant em TSX; veja Toque e gestos.

#Relacionados

  • Scroll: a referência do elemento, com todas as props e eventos.
  • Listas: listas virtualizadas com as mesmas props de rolagem.
  • Animação: valores animados, interpolate e diffClamp para efeitos vinculados à rolagem.
  • Formulários e o teclado: inputs, foco e o teclado.