Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

Documentation

Défilement

La vue défilante (scroll() en Rust, <Scroll> en TSX) est la vue défilante propre à la plateforme (UIScrollView sur iOS, un ScrollView sur Android) : les lancers, les rebonds, l’indicateur de défilement et le pull to refresh sont natifs, tout comme l’accrochage. Votre code est informé du défilement par des événements et peut lier le décalage à une valeur animée pour des effets qui suivent le doigt image par image.

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

Utilisez une vue défilante pour un contenu de taille connue et modérée : une page de réglages, un formulaire, un article, une rangée de puces. Pour des données longues ou non bornées, utilisez une liste, qui recycle les lignes et accepte les mêmes props de défilement.

#Une vue défilante

Une vue défilante est la zone d’affichage ; ses enfants forment le contenu. La zone d’affichage reçoit sa taille de son parent (généralement flex: 1, .flex_1()), et le contenu est disposé à la largeur de la zone d’affichage (vertical) ou à sa hauteur (horizontal), et aussi long que nécessaire.

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

La vue enfant ci-dessus est le conteneur de contenu de la vue défilante ; en TSX, contentStyle (ou contentClass) en crée un pour vous, en enveloppant les enfants dans une vue avec ce style, qui défile avec eux. flexGrow: 1 (.flex_grow(1.0)) rend le contenu au moins aussi haut que la zone d’affichage, de sorte qu’un contenu court peut être centré et qu’un contenu long défile toujours :

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

Une vue défilante horizontale prend horizontal (.horizontal()) et une rangée comme enfant :

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

En écriture de droite à gauche (voir Appareil et environnement), une vue défilante horizontale démarre à son extrémité droite et conserve sa distance par rapport à celle-ci.

Les doigts font défiler les vues défilantes. Sur le bureau (macOS, Windows, Linux) et dans le navigateur, la souris défile comme dans les applications de bureau : avec sa molette (Maj + molette défile horizontalement), le pavé tactile, le clavier et l’indicateur de défilement, qui s’affiche quand la souris survole la vue et se manipule comme une barre de défilement ; les glissements de souris sont réservés aux pans, aux responders et à la sélection de texte de l’application. Les écrans tactiles des ordinateurs de bureau font défiler les vues défilantes comme le font les doigts. Les vues horizontales y conservent leur indicateur même avec scrollbar={false}, car c’est ainsi qu’une souris sans molette horizontale les déplace.

#Props de comportement

PropRustTypeDéfautDescription
horizontal.horizontal()booleanfalseDéfile horizontalement.
scrollable.scrollable(..)booleantrueL’utilisateur peut le faire défiler. Le défilement depuis le code fonctionne toujours.
bounces.bounces(..)booleantrueDéfilement élastique au-delà des bords.
scrollbar.scrollbar(..)booleantrueL’indicateur de défilement s’affiche. Sur le bureau et dans les navigateurs de bureau, une vue horizontale le conserve : c’est la barre de défilement de la souris.
contentInset.content_inset(EdgeInsets { .. })Partial<Edges>Espace autour du contenu, dans lequel on peut défiler.
contentOffset.content_offset(point(..)){ x?, y? }Position de départ.
decelerationRate.deceleration(rate)"normal" | "fast" | numbercelle de la plateformeLa vitesse à laquelle un lancer ralentit : "normal", "fast", ou la vitesse conservée par milliseconde (0.998 est normal, 0.99 rapide ; 0 est la valeur par défaut de la plateforme).

#Pagination et accrochage

L’accrochage est décidé nativement au moment où le doigt se lève, à partir du décalage au relâchement, du glissement projeté et de la vitesse ; la vue défilante glisse ensuite vers l’arrêt choisi, sans aucune intervention de votre code.

PropRustTypeDescription
pagingEnabled.paging(true)booleanS’arrête aux multiples de la taille de la zone d’affichage.
snapToInterval.snap_interval(px(..))numberS’arrête aux multiples de cette longueur (0 : désactivé).
snapToOffsets.snap_offsets(vec![..])number[]S’arrête à ces décalages.
snapToAlignment.snap_alignment(SnapAlignment::Center)"start" | "center" | "end"Avec snapToInterval : quel point d’un élément s’aligne avec la zone d’affichage.
disableIntervalMomentum.disable_interval_momentum(true)booleanUn lancer avance d’un arrêt au maximum.

Un pager : des pages aussi larges que la vue défilante, et un indicateur mis à jour à la fin du glissement.

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

Un carrousel de cartes qui s’accrochent au centre, une carte par lancer :

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

Les listes acceptent les mêmes props d’accrochage, ce qui en fait le meilleur choix pour un long carrousel.

#Événements de défilement

PropRustPayloadQuand
onScroll.on_scroll(..)ScrollEventLe décalage a changé. Au plus une fois par image ; scrollEventThrottle espace davantage les événements.
scrollEventThrottle.event_throttle(ms)numberNombre minimal de millisecondes entre les événements onScroll.
onScrollDragStart.on_scroll_drag_start(..){ offset }L’utilisateur a commencé à faire glisser.
onScrollDragEnd.on_scroll_drag_end(..){ offset }L’utilisateur a levé le doigt.
onMomentumStart.on_momentum_start(..){ offset }Le glissement consécutif à un lancer a commencé.
onMomentumEnd.on_momentum_end(..){ offset }Le glissement est terminé (y compris lorsqu’un nouveau geste le rattrape).
onScrollEnd.on_scroll_end(..){ offset }Un défilement de l’utilisateur s’est immobilisé : après le glissement, ou à la fin d’un geste sans glissement.

ScrollEvent contient le décalage (offset, un Point ; identique en Rust), la taille du contenu, zoom inclus (contentSize, un Size ; content_size), la zone d’affichage (size) et zoomScale (zoom_scale). Les autres événements contiennent le décalage : { offset } avec un Point en TSX, un Point<Points> en 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)

#Effets liés au défilement : scrollValue

scrollValue (.scroll_value(&value)) prend une valeur animée et y écrit nativement le décalage de défilement (selon l’axe principal), dans la même image que celle où le contenu se déplace. Liez cette valeur, ou des nœuds qui en dérivent avec interpolate, diffClamp (diff_clamp) ou de l’arithmétique, à des styles.

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

Un en-tête qui disparaît en glissant quand le contenu défile vers le haut et revient dès qu’il défile vers le bas est diffClamp du décalage :

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

Par défaut, les interpolations sont bornées en dehors de leur plage d’entrée : tirer au-delà du haut (décalage négatif) maintient donc l’effet à sa valeur finale ; passez extrapolate: "extend" (interpolate_with(.., Interpolation::extrapolate(Extrapolate::Extend)) en Rust) pour un en-tête extensible qui grandit à mesure que vous tirez.

#Pull to refresh

refreshing et onRefresh (.refreshing(..) et .on_refresh(..)) ajoutent le contrôle de pull-to-refresh de la plateforme. Le contrôle s’affiche tant que refreshing vaut true ; activez-le dans onRefresh et désactivez-le une fois le travail terminé.

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)

Sur iOS, le contrôle est UIRefreshControl ; dans l’idiome Mac de Mac Catalyst, NSH affiche son propre spinner avec le même comportement. Les listes acceptent les mêmes props. Sur le bureau et dans le navigateur, le geste de tirage est celui d’un écran tactile (ou d’un trackpad macOS, qui tire au-delà du haut), puisque la souris défile sans glisser ; proposez-y un autre moyen d’actualiser, tel qu’un bouton ou un raccourci clavier.

#Un indicateur personnalisé

L’apparence du tirage est au choix de l’application. refreshIndicator={{ height }} indique que l’application dessine son propre indicateur, height points de haut : le spinner de la plateforme est masqué, un tirage déclenche l’actualisation dès qu’il atteint height, et pendant l’actualisation height restent au-dessus du contenu pour l’indicateur. pullValue alimente une valeur animée avec la distance dont le contenu est tiré au-delà du haut : 0 au repos, croissante avec le tirage, et height pendant l’actualisation. Dessinez l’indicateur par-dessus le haut de la vue défilante et interpolez ses styles à partir de la valeur : ils suivent le doigt sur le thread UI, sans JS à chaque image.

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

Gardez height dans la limite de la moitié de la zone d’affichage : au-delà du haut, le contenu résiste d’autant plus au doigt qu’il va loin, et une hauteur trop grande n’est jamais atteinte. Sur iOS, un indicateur propre à l’application remplace UIRefreshControl par le tirage que NSH détecte lui-même, comme dans l’idiome Mac.

#Zoom

Une vue défilante (les listes conservent une échelle de 1) zoome lorsque son échelle de zoom maximale est supérieure à son échelle minimale : le pincement met le contenu à l’échelle, et une fois zoomé, il défile dans les deux sens.

PropRustTypeDéfautDescription
minimumZoomScale.zoom_range(min, max)number1Le zoom minimal.
maximumZoomScale.zoom_range(min, max)number1Le zoom maximal ; au-dessus du minimum, le pincement zoome.
zoomScale.zoom(scale)numberZoome à cette échelle quand la prop change ; le zoom de l’utilisateur est conservé sinon.
bouncesZoom.bounces_zoom(..)booleantrueLe pincement peut dépasser les limites, puis revient élastiquement.
zoomable.zoomable(..)booleantrueL’utilisateur peut pincer pour zoomer.
centerContent.center_content(..)booleanfalseUn contenu plus petit que la zone d’affichage (dézoomé) est centré.

L’événement de défilement indique l’échelle de zoom et la taille du contenu zoomé. Depuis le code, zoom_to(scale, center, animated) / zoomTo({ scale, x, y, animated }) zoome de sorte que le point du contenu (en points non zoomés) se retrouve au milieu de la zone d’affichage ; le double appui pour zoomer est un gestionnaire de pression qui l’appelle :

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

Le texte et les canvas à l’intérieur d’une vue défilante zoomée sont redessinés à la nouvelle résolution à la fin du zoom, et restent ainsi nets sur iOS. Sur Android, NSH zoome avec une vue défilante qui lui est propre : elle met à l’échelle autour du point focal du pincement, se déplace avec n’importe quel nombre de doigts, accepte les lancers et rebondit élastiquement aux bords et aux limites de zoom. Android déclenche un pincement une fois les doigts écartés d’environ 27 mm, comme le fait le détecteur de gestes du système.

#Défiler depuis le code

Donnez une ref à la vue défilante (element_ref() et .element_ref(&scroller) en Rust, un ref en TSX) et appelez ses méthodes. Les commandes s’exécutent après les autres changements de la même mise à jour, de sorte que défiler vers un contenu que vous venez d’ajouter fonctionne.

RustTSXDescription
scroll_to(point, animated)scrollTo({ x?, y?, animated? })Défile vers un décalage. Animé par défaut en TSX.
zoom_to(scale, center, animated)zoomTo({ scale, x?, y?, animated? })Vues défilantes zoomables : zoome à scale (borné aux limites), autour du point du contenu ou du milieu.
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))

Les listes ajoutent scrollToIndex, scrollToOffset et scrollToEnd ; voir Listes.

#Vues défilantes et clavier

NSH garde de lui-même visible un champ de saisie ayant le focus : lorsque le clavier s’affiche, la vue défilante qui entoure le champ reçoit un inset inférieur de la hauteur du clavier et fait défiler le champ jusqu’à le rendre visible. Cela se fait nativement, sans code dans votre application. Utilisez env(keyboard-inset-height) comme padding inférieur (Contenu en dehors des vues défilantes) pour le contenu situé en dehors des vues défilantes, comme une barre de message fixée en bas.

Deux props déterminent comment le défilement et les appuis interagissent avec le clavier :

PropRustTypeDéfautDescription
keyboardDismissMode.keyboard_dismiss_mode(KeyboardDismissMode::OnDrag)"none" | "on-drag" | "interactive""none"Indique si faire glisser le contenu masque le clavier. "interactive" laisse le clavier suivre le doigt vers le bas sur iOS ; sur Android, il se comporte comme "on-drag".
keyboardShouldPersistTaps.keyboard_taps(KeyboardTaps::Handled)"never" | "always" | "handled""never"Ce que fait un appui lorsque le clavier est affiché.

keyboardShouldPersistTaps :

  • "never" : un appui en dehors du champ ayant le focus ne fait que masquer le clavier, et les boutons reçoivent l’appui suivant. L’utilisateur appuie une fois pour fermer, puis une autre fois pour presser.
  • "handled" : un appui sur un élément qui le gère (un bouton) passe à travers et le clavier reste affiché ; un appui sur un espace vide le masque. C’est généralement le bon choix pour les formulaires.
  • "always" : le clavier reste affiché à travers tous les appuis.

Les deux sont décidés nativement au moment de l’appui ou du glissement, sans attendre votre code. Pour masquer le clavier depuis le code, appelez dismiss_keyboard() en Rust ou Keyboard.dismiss() depuis nsh en TSX.

#Vues défilantes imbriquées

Une vue défilante horizontale à l’intérieur d’une vue verticale fonctionne comme prévu : chacune prend les glissements selon son propre axe. Deux vues défilantes sur le même axe s’imbriquent aussi (la vue interne défile en premier). Les responders qui ont besoin du doigt, comme un canvas de dessin à l’intérieur d’une vue défilante, le retirent aux vues défilantes qui les entourent : .keep_touches(true) en Rust, true retourné par onResponderGrant en TSX ; voir Toucher et gestes.

#Voir aussi

  • Scroll : la référence de l’élément, avec toutes les props et tous les événements.
  • Listes : listes virtualisées avec les mêmes props de défilement.
  • Animation : valeurs animées, interpolate et diffClamp pour les effets liés au défilement.
  • Formulaires et clavier : champs de saisie, focus et clavier.