Saltar al contenido
zabloo

ScrollView

Una ventana a un contenido más grande que el hueco que tienes para él. Recorta lo que se sale y deja que el jugador llegue al resto arrastrando, con la rueda o con el stick.

primitivedesde v1sin focus

Un ScrollView es una ventana a un contenido más grande que el sitio que tienes para él. Todo lo que pasa del borde se recorta —ni se dibuja, ni se puede tocar— y el jugador llega hasta ello arrastrando, con la rueda o con el stick derecho del mando. Piensa en una tienda con cuarenta objetos dentro de un panel donde caben seis: el panel es el ScrollView, y la lista de dentro es tan alta como cuarenta filas. En todo lo demás se comporta como una caja normal.

scrollview-list.viewIR v1
Viewport

Viewport: 960 × 340

A panel with a horizontal row of category chips that begins Weapons, Armour, Potions and runs off the right edge, over a vertical list of stock rows: Iron sword, Healing potion, Oak shield and a fourth partly visible, with a scrollbar showing there is more below.
ESTADO
  • hover — inactivo
  • pressed — inactivo
  • focused — inactivo
  • selected — inactivo
  • disabled — inactivo

Se lee del último frame dibujado

Parado: pulsa Ejecutar para dibujarlo en la GPU.

import { Column, Row, ScrollView, Text } from "@zabloo/react";


const ROWS = [
  { name: "Iron sword", detail: "Damage 12" },
  { name: "Healing potion", detail: "Restores 40 HP" },
  { name: "Oak shield", detail: "Block 8" },
  { name: "Torch", detail: "Lights the dark" },
  { name: "Rope, 20m", detail: "Sturdy" },
  { name: "Guild seal", detail: "Quest item" },
  { name: "Silver ring", detail: "Sells for 60" },
];

/**
 * Nine, so the row overflows at every viewport the stage offers (ZAB-153):
 * 9 × 108 plus its gaps is 1036, wider than the 872 even a desktop preset
 * leaves. A scroller with nothing past its edge demonstrates nothing.
 */
const CHIPS = [
  "Weapons",
  "Armour",
  "Potions",
  "Quest",
  "Junk",
  "Trinkets",
  "Books",
  "Tools",
  "Rations",
];

export default function ScrollViewList() {
  return (
    <Column
      // `align: "stretch"` on the ROOT is what hands the panel the view's own
      // width; centring it instead would pin the panel to its content and no
      // change of viewport could reach it (ZAB-153).
      layout={{ grow: 1, justify: "center", align: "stretch", padding: "{space.6}" }}
      style={{ background: "{color.bg}" }}
    >
      <Column
        id="panel"
        layout={{ padding: "{space.4}", gap: "{space.3}", align: "stretch" }}
        style={{
          background: "{color.surface}",
          radius: "{radius.lg}",
          borderWidth: "{border.hairline}",
          borderColor: "{color.line}",
        }}
      >
        {/* A horizontal scroller of chips: `axis` frees the width, `direction`
            keeps them in a row. A scrollable axis offers no width, so the
            labels inside never wrap. No `width` of its own — the panel's
            `align: "stretch"` gives it one, which is the whole reason a
            narrower viewport shows fewer chips. */}
        <ScrollView
          id="categories"
          axis="horizontal"
          scrollbar={false}
          layout={{ direction: "row", gap: "{space.2}" }}
        >
          {CHIPS.map((name) => (
            <Row
              key={name}
              layout={{ padding: "{space.2}", width: 108, justify: "center", align: "center" }}
              style={{
                background: "{color.slot}",
                radius: "{radius.pill}",
                borderWidth: "{border.hairline}",
                borderColor: "{color.line}",
              }}
            >
              <Text style={{ color: "{color.muted}", fontSize: "{text.xs}" }}>{name}</Text>
            </Row>
          ))}
        </ScrollView>

        {/* The vertical viewport: seven rows of 44 inside 180px of window, so
            there is always something below the fold. It always clips — paint
            and hit-testing both — so a row past the edge is neither drawn nor
            tappable. */}
        <ScrollView
          id="stock"
          layout={{
            height: 180,
            padding: "{space.1}",
            align: "stretch",
            gap: "{space.2}",
          }}
        >
          {ROWS.map((row) => (
            <Row
              key={row.name}
              layout={{ height: 44, padding: "{space.2}", gap: "{space.3}", align: "center" }}
              style={{ background: "{color.slot}", radius: "{radius.md}" }}
            >
              <Text layout={{ grow: 1 }} style={{ color: "{color.text}", fontSize: "{text.sm}" }}>
                {row.name}
              </Text>
              <Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>{row.detail}</Text>
            </Row>
          ))}
        </ScrollView>
      </Column>
    </Column>
  );
}
Dos scrollers en un mismo panel: unos chips que se salen de lado, unas filas de stock que se salen hacia abajo. Pulsa Run y arrastra cualquiera de los dos, y luego fíjate bien en la fila que corta el borde de abajo: ni se dibuja más allá de esa línea ni se puede tocar por debajo de ella, porque un ScrollView recorta siempre las dos cosas.

Las dos tablas de abajo coinciden, y la razón es justo lo que este nodo es: un ScrollView no lleva ningún estado que escribir. Normalmente lo que escribes y lo que se publica difieren porque un theme o un componente resolvieron algo; aquí no hay nada que resolver. El offset —el único número que un scroller tiene de verdad— no está nunca en la IR, porque es del jugador.

Props de autoría

Lo que escribes en @zabloo/react.

PropTipoPor defectoDescripción
axis"vertical" | "horizontal" | "both""vertical"Eje con scroll. "both" libera los dos.
scrollbarbooleantrueEl indicador superpuesto del SDK, visible solo mientras haya algo que recorrer.
childrenReactNodeabsentEl contenido.

Además de estas, todo componente acepta las props base del nodo —id, visible, disabled, layout, style, states, transition, autofocus, clip— más variant, que el theme resuelve al exportar y que nunca aparece en la IR.

Props de IR

Lo que llega al juego, cuando ya no queda capa de autoría.

PropTipoPor defectoDescripción
axis"vertical" | "horizontal" | "both""vertical"El eje en el que los hijos se miden sin restricción.
scrollbarbooleantrueIndicador de posición superpuesto, pintado por el SDK.
childrenZNode[][]Hijos de flujo normales.

El layout

Un ScrollView es un contenedor flex normal por los dos lados: su propio tamaño sale de su layout, y direction/justify/align/gap/padding colocan a sus hijos como lo haría cualquier contenedor. Solo cambia una cosa: en el eje con scroll, los hijos se miden sin restricción, así que toman su tamaño natural, y eso es lo que el jugador recorre. El padding cuenta como contenido: separa a los hijos y agranda los límites del scroll.

axis no es direction

axis dice por dónde puede desbordarse el contenido; layout.direction dice por dónde fluyen los hijos. Una tira horizontal de chips es axis: "horizontal" y direction: "row". Y como un eje con scroll no ofrece ancho a sus hijos, un Text dentro de un scroller horizontal no envuelve nunca.

El viewport lo dimensionas tú. Un ScrollView sin width/height se ciñe a su contenido, y entonces no hay nada que recorrer. grow por sí solo tampoco lo dimensiona: su tamaño medido es el contenido entero, así que nunca le llega nada sobrante. Para llenar lo que queda de un padre, pon a cero la base de ese eje: height: 0 con grow: 1 en una columna.

Comportamiento

Estados

Solo disabled — y sigue haciendo scroll mientras está deshabilitado, propio o heredado. El scroll no es una interacción de la que un control sea dueño, y un panel que el jugador no puede usar sigue siendo un panel que tiene que poder leer. Sus hijos conservan sus estados.

Focusable

No, y no le hace falta. El jugador arrastra, gira la rueda o empuja el stick derecho → el SDK mueve el offset → no se envía nada al juego. La misma maquinaria hace el auto-reveal que trae a la vista un nodo que acaba de recibir el focus. Arrastrar pasando por encima de un Button hace scroll; no se convierte en un clic sobre él.

Recorte

Siempre, tanto al pintar como al hacer hit-testing, y un clip: false explícito se ignora. Una fila que pasa del borde ni se dibuja ni se puede tocar. El offset es estado de ejecución del que es dueño el SDK —nunca se escribe, nunca se serializa— y se vuelve a acotar a max(0, contenido − viewport) en cada relayout, así que cerrar un Collapse dentro de una lista deja la lista en su nuevo final en vez de colgando más allá.

Actions

Ninguna: el juego no oye nada cuando un jugador hace scroll. En la v1 no hay onScroll y el offset no es enlazable; un juego lo mueve por el host channel con SetScroll(id, x, y). Quedan aplazadas, todas como extensiones compatibles: un offset escrito o enlazable, la inercia, el snapping y una barra de scroll estilizable.

Degradación

En un SDK más antiguo

En un SDK más antiguo se ve la lista entera de golpe, saliéndose del panel que tendría que contenerla. No falta nada; no hay scroll.

Como Container: el contenido se ve completo, desbordando a su padre, sin nada que recorrer y sin nada recortado. Todo sigue ahí y sigue siendo legible — lo que se pierde es la ventana, que es la forma menos destructiva en que un viewport puede fallar.

Composición

Repetir y hacer scroll son capacidades separadas, y se componen: una lista guiada por datos es una <List> dentro de un <ScrollView>, no una prop de ninguno de los dos.

  • Un viewport vertical con tamaño — para cualquier panel cuyo contenido pueda pasarse del hueco permitido: un inventario, un registro de misiones, una página de ajustes.
  • Una tira horizontal — para una fila de categorías o filtros que no vaya a caber de lado a lado en un móvil.
  • Un scroller alrededor de una <List> — cuando las filas salgan del array del propio juego y puedan ser cualquier cantidad.
// A sized viewport. Without width/height it would hug its content.
<ScrollView layout={{ width: 460, height: 340, align: "stretch", gap: 4 }}>
{items.map((item) => <ItemRow key={item.id} item={item} />)}
</ScrollView>

// A horizontal strip: axis frees the width, direction keeps the chips in a row.
<ScrollView axis="horizontal" scrollbar={false} layout={{ direction: "row", width: 460, gap: 8 }}>
{categories.map((name) => <Chip key={name} name={name} />)}
</ScrollView>

// Data-driven and scrollable: two nodes, one for each capability.
<ScrollView layout={{ width: 460, height: 340, align: "stretch", gap: 8 }}>
<List items="shop.items" as="it" keyPath="id">
  {(it) => <Row layout={{ gap: 12 }}><Text bind={it("name")} /></Row>}
</List>
</ScrollView>

Relacionado