Saltar al contenido
zabloo

TextInput

Una línea de texto en la que el jugador escribe. Es el único nodo con cursor — un punto de inserción dentro de un contenido que se está escribiendo.

primitivedesde v1focusable

Un TextInput es una línea en la que el jugador escribe. Usas uno siempre que el juego necesita palabras que no podría haber ofrecido como opciones: el nombre de un personaje, un buscador, un código para canjear. Piensa en una pantalla de perfil: el campo Name escribe cada tecla directamente en los datos del propio juego a través de su binding, y Enter es una señal aparte que significa acéptalo. En la v1 es de una sola línea, y no crece con lo que se escribe dentro.

textinput-field.viewIR v1
Two fields in a panel: Character name holding the text Kira, with the line Greeting: Kira under it reading the same value, and an empty Search field showing its Filter items placeholder in a dimmer grey.
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, Text, TextInput } from "@zabloo/react";


const FIELD = {
  background: "{color.slot}",
  radius: "{radius.md}",
  borderWidth: "{border.hairline}",
  borderColor: "{color.line-strong}",
  color: "{color.text}",
  fontSize: "{text.sm}",
} as const;

/** The focus ring lives in `states`, like every other visual answer to input. */
const FIELD_STATES = {
  focused: { style: { borderWidth: "{border.focus}", borderColor: "{color.brand}" } },
  empty: { style: { color: "{color.faint}" } },
} as const;

export default function TextInputField() {
  return (
    <Column
      layout={{ grow: 1, justify: "center", align: "center", padding: "{space.6}" }}
      style={{ background: "{color.bg}" }}
    >
      <Column
        id="panel"
        layout={{ width: 400, padding: "{space.5}", gap: "{space.4}", align: "stretch" }}
        style={{
          background: "{color.surface}",
          radius: "{radius.lg}",
          borderWidth: "{border.hairline}",
          borderColor: "{color.line}",
        }}
      >
        <Column layout={{ gap: "{space.2}", align: "stretch" }}>
          <Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Character name</Text>
          {/* maxLength bounds what the PLAYER can type; a longer string pushed
              by the game is still shown whole. */}
          <TextInput
            id="name"
            value={{ bind: "profile.name" }}
            placeholder="Your name"
            maxLength={16}
            onSubmit="name-accept"
            width={360}
            padding={10}
            style={FIELD}
            states={FIELD_STATES}
          />
          <Row layout={{ gap: "{space.1}", align: "center" }}>
            <Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Greeting:</Text>
            {/* The same path, read. No callback, no game code. */}
            <Text bind="profile.name" style={{ color: "{color.muted}", fontSize: "{text.xs}" }} />
          </Row>
        </Column>

        <Column layout={{ gap: "{space.2}", align: "stretch" }}>
          <Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Search</Text>
          {/* Empty, so `states.empty` is what paints the placeholder — and
              `onChange` is the live hook a filter-as-you-type hangs off. */}
          <TextInput
            id="search"
            value={{ bind: "search.query" }}
            placeholder="Filter items"
            onChange="search-typed"
            onSubmit="search-run"
            width={360}
            padding={10}
            style={FIELD}
            states={FIELD_STATES}
          />
        </Column>
      </Column>
    </Column>
  );
}
Dos campos, uno con un valor y otro vacío. Pulsa Run y escribe en el primero, y mira la línea de debajo: es un Text enlazado al mismo path, siguiendo las teclas sin código del juego por medio. El segundo campo enseña lo que el estado vacío le hace a un placeholder.

Todos los controles anteriores a este producían un valor señalando a una geometría: un booleano, un número, un índice. Este tiene una posición dentro de sí mismo, y eso es lo que lo convierte en un tipo en vez de en un Text con un flag.

Las dos tablas de abajo se diferencian en un par de filas nada más, y las dos son comodidades: width y padding son números que escribes tú y que se resuelven en el layout normal del nodo antes de publicar nada. Lo que llega al juego es un campo, su texto y los dos nombres que puede disparar.

Props de autoría

Lo que escribes en @zabloo/react.

PropTipoPor defectoDescripción
valueBindable<string>""Texto actual, o un binding de lectura y escritura.
placeholderstringabsentPista que se ve mientras el campo está vacío.
onChange / onSubmitstringabsentEl hook en vivo y el de confirmar.
maxLengthnumberunboundedTope de lo que el jugador puede escribir.
widthnumber220Ancho del campo a lo largo de su línea, en px.
paddingnumber8Espacio entre la caja y el texto, en px.

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. Un campo no cambia de tamaño con lo que se escribe dentro, así que necesita un ancho propio; un layout explícito le sigue ganando, y grow: 1 rellena una fila.

Props de IR

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

PropTipoPor defectoDescripción
valueBindable<string>""Texto actual, o un binding de lectura y escritura.
placeholderstringabsentSe ve mientras el valor está vacío.
onChangestringabsentNamed action que se dispara tras cada edición.
onSubmitstringabsentNamed action que se dispara cuando el jugador confirma (Enter).
maxLengthnumberunboundedTope de lo que el jugador puede escribir.

Es una hoja con contenido, como Text e Image: no acepta hijos y pinta su propio valor por el camino de texto de siempre.

El valor

value es o una cadena inicial literal o un binding de lectura y escritura: el SDK escribe cada cambio en su almacén de datos y avisa al juego. Un <Text bind> sobre el mismo path sigue lo que se escribe sin código del juego por medio — la figura de arriba es exactamente eso.

maxLength acota lo que se teclea, no los datos. Una cadena más larga que el juego empuje por un binding se enseña entera: truncar en silencio los datos del propio juego sería mentir sobre lo que tiene.

Dos hooks, y uno que no existe

  • onChange se dispara en cada edición: el hook en vivo, simétrico del Slider.onChange. De aquí cuelga una búsqueda que filtra mientras escribes.
  • onSubmit se dispara cuando el jugador confirma el campo: la búsqueda que se lanza, el nombre que se acepta.
A propósito no hay onCommit ni commit al perder el focus

A diferencia de un arrastre, un campo de texto ya tiene un gesto explícito de confirmación —Enter—, así que el par en vivo/asentado existe; lo único que cambia es el nombre del segundo hook, y cambia porque el gesto es otro. El submit es algo que el jugador hace, mientras que el commit de un slider es algo que deja de hacer. El commit al perder el focus no sobreviviría a un juego de todas formas: con navegación por flechas y por mando el focus sale del campo cada vez que el jugador lo cruza, así que un commit al salir dispararía valores “asentados” que no lo son — y un campo enlazado ya ha escrito cada edición en los datos, así que al salir no se pierde nada.

Una sola línea en la v1

El nodo se mide como una línea de texto y hace scroll horizontal de su contenido para mantener el cursor a la vista. Nunca se inserta un salto de línea; uno pegado se convierte en un espacio. El campo multilínea es una extensión compatible sobre el algoritmo de wrap —un cursor con fila además de columna, y una selección que cruza líneas— y queda aplazado.

El placeholder

El placeholder se pinta con el estilo de texto del propio campo mientras el valor está vacío, y quien lo estiliza es el estado empty:

"states": { "empty": { "style": { "color": "{color.muted}" } } }

Ni un segundo campo de color ni un slot: el nodo ya es dueño del pintado del texto, así que un placeholder es ese mismo pintado con otra cadena. empty abre el orden de mezcla — es lo más débil que un control dice sobre su valor, así que cualquier cosa declarada para un campo con focus o seleccionado le gana.

Comportamiento

Estados

empty, más hover y focused — y disabled, propio o heredado. No hay pressed: una pulsación sobre un campo coloca el cursor, no activa nada, así que no hay estado de “pulsado” que vestir. Un campo deshabilitado no acepta cursor ni teclas, y sigue enseñando lo que tiene.

Focusable

Sí, salvo que esté disabled. Se queda las flechas para mover su cursor pero las devuelve en los extremos: al final del texto, una pulsación más sale del campo. A propósito distinto del Slider, que nunca suelta las flechas de su eje — salir andando de una cadena larga tecla a tecla no es un precio razonable.

Pintado

El cursor y el resaltado de la selección los pinta el SDK, los dos a partir del style.color del propio campo — el mismo “color del contenido de este nodo” que tiñe glifos e imágenes. Su parpadeo y su estilo son comportamiento, no IR: el mismo reparto que mantiene la barra de scroll del ScrollView fuera del formato.

Actions

El jugador escribe un carácter → el SDK escribe la cadena nueva en los datos → se dispara onChange. El jugador pulsa Enter → el texto no cambia → se dispara onSubmit por su cuenta. En el otro sentido, SetText(id, text) sustituye el buffer y deja el cursor al final, que es por donde empezaría a escribir alguien a quien le acaban de dar un valor ya puesto.

Degradación

En un SDK más antiguo

En un SDK más antiguo se ve la caja del campo a su tamaño exacto y nada más: ni texto dentro, ni forma de escribir nada.

Como un Container vacío: es una hoja, así que lo que sobrevive es la caja — su fondo, su borde, su tamaño. El layout no se mueve, porque el tamaño de un campo nunca fue función de su contenido.

Kira

Cómo oye esto el juego

El binding es el texto; la action es el momento en que el jugador lo ha confirmado. Aquí es donde más fácil resulta distinguir los dos canales. Cada edición se escribe en el almacén de datos según ocurre, así que la copia del juego nunca está vieja; la action no lleva texto ninguno, solo la noticia de que se ha pulsado Enter. Un campo de nombre que solo importa una vez aceptado se suscribe únicamente a onSubmit y no mira las teclas.

using UnityEngine;
using Zabloo;

[RequireComponent(typeof(ZablooDocument))]
public sealed class ProfileScreen : MonoBehaviour
{
  ZablooDocument _doc;

  void Start()
  {
      _doc = GetComponent<ZablooDocument>();
      _doc.OnAction += OnZablooAction;

      // The field starts on what the game holds — the same path it writes back.
      _doc.SetData("profile.name", SaveGame.PlayerName);
  }

  void OnDestroy()
  {
      if (_doc != null) _doc.OnAction -= OnZablooAction;
  }

  void OnZablooAction(string action)
  {
      // Enter, not every keystroke: this is the gesture that means "accept it".
      if (action == "name-accept") CommitPlayerName();
  }
}

Composición

Un campo más un Text enlazado al mismo path es el patrón entero de “vista previa en vivo”, y no necesita código.

  • El campo canónico — enlazado, con tope y confirmado con Enter; usa esta forma para todo lo que el jugador nombre.
  • Un campo con un Text sobre el mismo path — cuando el valor tenga que verse en otro sitio de la pantalla según se escribe.
  • Un campo con grow: 1 dentro de un Row — para una barra de búsqueda, donde el campo debe quedarse el ancho que le deje el botón de al lado.
// The canonical field: bound, capped, confirmed with Enter.
<TextInput
value={{ bind: "profile.name" }}
placeholder="Your name"
maxLength={16}
onSubmit="name-accept"
states={{ empty: { style: { color: "{color.muted}" } } }}
/>

// A live preview of what is being typed. No callback in between.
<Text bind="profile.name" />

// A search bar: the field takes the leftover space, the button keeps its size.
<Row layout={{ gap: 8, align: "center" }}>
<TextInput value={{ bind: "search.query" }} onChange="search-typed" layout={{ grow: 1 }} />
<Button onClick="search-run"><Text>Search</Text></Button>
</Row>

Relacionado