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.
Viewport: 960 × 340

- 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>
);
}{
"v": 1,
"tokens": {
"color.bg": "#0b0d13",
"color.surface": "#0e1016",
"color.raised": "#14141a",
"color.line": "#ffffff1f",
"color.line-strong": "#ffffff24",
"color.text": "#ffffff",
"color.muted": "#a1a1aa",
"color.faint": "#8a8a93",
"color.on-brand": "#ffffff",
"color.brand": "#8b5cf6",
"color.brand-strong": "#7c3aed",
"color.brand-hover": "#8b5cf6",
"color.brand-pressed": "#6d28d9",
"color.brand-soft": "#8b5cf61f",
"color.gold": "#fcd34d",
"color.slot": "#ffffff08",
"color.danger": "#f87171",
"radius.sm": 6,
"radius.md": 10,
"radius.lg": 14,
"radius.pill": 999,
"border.hairline": 1,
"border.focus": 2,
"space.1": 4,
"space.2": 8,
"space.3": 12,
"space.4": 16,
"space.5": 20,
"space.6": 24,
"text.xs": 11,
"text.sm": 13,
"text.md": 15,
"text.lg": 17,
"motion.fast": 120,
"motion.loop": 900
},
"views": {
"scrollview-list": {
"type": "Container",
"layout": {
"direction": "column",
"grow": 1,
"justify": "center",
"align": "stretch",
"padding": "{space.6}"
},
"style": {
"background": "{color.bg}"
},
"children": [
{
"type": "Container",
"id": "panel",
"layout": {
"direction": "column",
"padding": "{space.4}",
"gap": "{space.3}",
"align": "stretch"
},
"style": {
"background": "{color.surface}",
"radius": "{radius.lg}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "ScrollView",
"id": "categories",
"layout": {
"direction": "row",
"gap": "{space.2}"
},
"axis": "horizontal",
"scrollbar": false,
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Weapons"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Armour"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Potions"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Quest"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Junk"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Trinkets"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Books"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Tools"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"padding": "{space.2}",
"width": 108,
"justify": "center",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.pill}",
"borderWidth": "{border.hairline}",
"borderColor": "{color.line}"
},
"children": [
{
"type": "Text",
"style": {
"color": "{color.muted}",
"fontSize": "{text.xs}"
},
"text": "Rations"
}
]
}
]
},
{
"type": "ScrollView",
"id": "stock",
"layout": {
"height": 180,
"padding": "{space.1}",
"align": "stretch",
"gap": "{space.2}"
},
"children": [
{
"type": "Container",
"layout": {
"direction": "row",
"height": 44,
"padding": "{space.2}",
"gap": "{space.3}",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}"
},
"children": [
{
"type": "Text",
"layout": {
"grow": 1
},
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Iron sword"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Damage 12"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"height": 44,
"padding": "{space.2}",
"gap": "{space.3}",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}"
},
"children": [
{
"type": "Text",
"layout": {
"grow": 1
},
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Healing potion"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Restores 40 HP"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"height": 44,
"padding": "{space.2}",
"gap": "{space.3}",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}"
},
"children": [
{
"type": "Text",
"layout": {
"grow": 1
},
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Oak shield"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Block 8"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"height": 44,
"padding": "{space.2}",
"gap": "{space.3}",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}"
},
"children": [
{
"type": "Text",
"layout": {
"grow": 1
},
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Torch"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Lights the dark"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"height": 44,
"padding": "{space.2}",
"gap": "{space.3}",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}"
},
"children": [
{
"type": "Text",
"layout": {
"grow": 1
},
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Rope, 20m"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Sturdy"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"height": 44,
"padding": "{space.2}",
"gap": "{space.3}",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}"
},
"children": [
{
"type": "Text",
"layout": {
"grow": 1
},
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Guild seal"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Quest item"
}
]
},
{
"type": "Container",
"layout": {
"direction": "row",
"height": 44,
"padding": "{space.2}",
"gap": "{space.3}",
"align": "center"
},
"style": {
"background": "{color.slot}",
"radius": "{radius.md}"
},
"children": [
{
"type": "Text",
"layout": {
"grow": 1
},
"style": {
"color": "{color.text}",
"fontSize": "{text.sm}"
},
"text": "Silver ring"
},
{
"type": "Text",
"style": {
"color": "{color.faint}",
"fontSize": "{text.xs}"
},
"text": "Sells for 60"
}
]
}
]
}
]
}
]
}
}
}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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| axis | "vertical" | "horizontal" | "both" | "vertical" | Eje con scroll. "both" libera los dos. |
| scrollbar | boolean | true | El indicador superpuesto del SDK, visible solo mientras haya algo que recorrer. |
| children | ReactNode | absent | El 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.
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
| axis | "vertical" | "horizontal" | "both" | "vertical" | El eje en el que los hijos se miden sin restricción. |
| scrollbar | boolean | true | Indicador de posición superpuesto, pintado por el SDK. |
| children | ZNode[] | [] | 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 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
Buttonhace scroll; no se convierte en un clic sobre él.- Recorte
Siempre, tanto al pintar como al hacer hit-testing, y un
clip: falseexplí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 amax(0, contenido − viewport)en cada relayout, así que cerrar unCollapsedentro 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
onScrolly el offset no es enlazable; un juego lo mueve por el host channel conSetScroll(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 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>