Saltar al contenido
zabloo

Toggle

Un control que está encendido o apagado. La checkbox, el switch y el radio son el mismo nodo — lo que cambia es cómo va vestido y si está dentro de un grupo.

primitivedesde v1focusable

Un Toggle tiene uno de dos estados: encendido o apagado. Lo usas siempre que un ajuste es un sí o un no, y siempre que el jugador elige exactamente una opción de una lista corta. Piensa en una pantalla de ajustes: la checkbox Sound effects es un Toggle por su cuenta, con un booleano que el juego lee; la fila Low / Medium / High de debajo son otros tres compartiendo un único valor, que es la razón de que elegir High apague Low sin que ninguno de los dos sepa que el otro existe.

toggle-controls.viewIR v1
An Audio and video panel with a purple checkbox carrying a white mark labelled Sound effects, a switch turned on labelled Fullscreen, and under the heading Quality a pair of radio options, Low and High, with High selected.
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 { Checkbox, Column, Radio, RadioGroup, Switch, Text } from "@zabloo/react";

export default function ToggleControls() {
  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}",
        }}
      >
        <Text style={{ color: "{color.text}", fontSize: "{text.lg}" }}>Audio &amp; video</Text>

        {/* A read/write binding: tapping writes the new boolean into the game's
            data and notifies it. `onChange` is the other leg — the named action. */}
        <Checkbox
          id="sfx"
          checked={{ bind: "settings.sfx" }}
          onChange="sfx-changed"
          box={{
            background: "{color.slot}",
            radius: "{radius.sm}",
            borderWidth: "{border.hairline}",
            borderColor: "{color.line-strong}",
          }}
          checkedBox={{ background: "{color.brand-strong}", borderColor: "{color.brand}" }}
          mark={{ background: "{color.on-brand}" }}
        >
          <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Sound effects</Text>
        </Checkbox>

        {/* The same primitive. The knob "moves" because each slot justifies it
            to a different end of the track — layout, not animation. */}
        <Switch
          id="fullscreen"
          checked={{ bind: "settings.fullscreen" }}
          track={{ background: "{color.slot}", radius: "{radius.pill}" }}
          checkedTrack={{ background: "{color.brand-strong}" }}
          knob={{ background: "{color.text}", radius: "{radius.pill}" }}
        >
          <Text style={{ color: "{color.text}", fontSize: "{text.sm}" }}>Fullscreen</Text>
        </Switch>

        <Text style={{ color: "{color.faint}", fontSize: "{text.xs}" }}>Quality</Text>

        {/* One value for the whole group: each Radio is checked while its own
            value equals it, so the losing option never has to be told. */}
        <RadioGroup
          value={{ bind: "settings.quality" }}
          onChange="quality-changed"
          layout={{ gap: "{space.2}" }}
        >
          <Radio
            value="low"
            box={{
              radius: "{radius.pill}",
              borderWidth: "{border.hairline}",
              borderColor: "{color.line-strong}",
            }}
            checkedBox={{ borderColor: "{color.brand}" }}
            mark={{ background: "{color.brand}", radius: "{radius.pill}" }}
          >
            <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>Low</Text>
          </Radio>
          <Radio
            value="high"
            box={{
              radius: "{radius.pill}",
              borderWidth: "{border.hairline}",
              borderColor: "{color.line-strong}",
            }}
            checkedBox={{ borderColor: "{color.brand}" }}
            mark={{ background: "{color.brand}", radius: "{radius.pill}" }}
          >
            <Text style={{ color: "{color.muted}", fontSize: "{text.sm}" }}>High</Text>
          </Radio>
        </RadioGroup>
      </Column>
    </Column>
  );
}
Una checkbox, un switch y dos radios: cuatro instancias de un mismo tipo de nodo. Pulsa Run y tócalos, y fíjate en lo que cuesta cada toque: los dos primeros cambian un booleano propio, mientras que los radios mueven un único valor compartido, así que elegir uno suelta el otro a la vista sin que se pasen ningún mensaje.

Las dos tablas de abajo están más lejos entre sí que en ninguna otra página, y esa distancia es la razón de ser de la capa de componentes: box, checkedBox, mark, track y knob no son props de este nodo. Son los estilos de los dos hijos que el componente construye por ti, y lo que se publica son esos hijos, nunca las palabras.

Props de autoría

No existe un componente <Toggle>. Los slots de abajo son posicionales, y los cuatro controles que emiten este nodo son el único sitio donde esa convención está escrita — por eso nunca los rellenas a mano. Todos comparten estas props.

PropTipoPor defectoDescripción
checkedBindable<boolean>falseEstado inicial, o un binding de lectura y escritura.
onChangestringabsentNamed action que se dispara en cada cambio.
sizenumber22Tamaño del indicador en px — el lado de la caja, o el alto del track del switch.
childrenReactNodeabsentLa etiqueta. Tocarla también cambia el estado.

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. <Checkbox> añade box, checkedBox y mark; <Switch> añade track, checkedTrack y knob; <Radio> exige un value.

Props de IR

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

PropTipoPor defectoDescripción
checkedBindable<boolean>falseEstado inicial, o un binding de lectura y escritura.
valuestring | numberabsentEl valor de esta opción dentro de un grupo "exclusive-check".
onChangestringabsentNamed action que se dispara en cada cambio.
childrenZNode[][]Slots posicionales: indicador marcado, indicador sin marcar, etiqueta.

Los slots del indicador

Pintar es implícito —no hay una capa de comandos de dibujo—, así que la marca o el knob se componen, no los dibuja un primitive nuevo:

SlotSe ve
children[0]solo mientras está checked
children[1]solo mientras está sin checked
children[2..]siempre — es la etiqueta

Los slots entran y salen del layout con la semántica de display:none, el mismo y único mecanismo de ocultar que un Collapse usa para su contenido. Un switch mueve su knob intercambiando dos slots con distinto justify; una checkbox enseña su marca. Cada slot pinta el indicador entero tal y como se ve en ese estado, que es lo que mantiene en pie la regla de que nada estiliza a un descendiente por estado.

Los dos slots del indicador comparten una caja en el flujo: se miden como un solo item, se quedan con el mayor de los dos, y ambos reciben el mismo rect. Eso es lo que les permite hacer un crossfade sin que la etiqueta se mueva.

Comportamiento

Estados

checked, más hover, pressed y focused — y disabled, propio o heredado. Un toggle deshabilitado conserva su estado checked: lo que tiene y si el jugador puede cambiarlo son dos afirmaciones distintas, y por eso disabled se mezcla el último.

Focusable

Sí, salvo que esté disabled. El jugador lo toca, pulsa Enter mientras lo tiene con el focus, o pulsa A en el mando — y ninguna de las tres cosas llega mientras está deshabilitado.

Valor

Por su cuenta lleva un booleano: checked es o un valor inicial literal o un binding de lectura y escritura que el SDK escribe en su almacén de datos. Dentro de un grupo "exclusive-check" es derivado del value del grupo y no se guarda nunca por nodo: tocarlo escribe su propio value en el del grupo.

Actions

El jugador lo toca → el SDK da la vuelta al valor y lo escribe en los datos → se dispara onChange con la named action que le hayas puesto. Se dispara en cada cambio venga de donde venga, incluido el SetChecked del propio juego. Dentro de un grupo solo se dispara cuando esta opción se queda con la selección: un radio nunca se apaga a sí mismo, así que el que la pierde no dice nada.

Con una transition, los indicadores hacen crossfade

El SDK interpola un progreso de 0..1 para el checked y multiplica por él la opacidad de cada slot: children[0] aparece mientras children[1] se va. Para eso sirve la caja compartida: la etiqueta no se mueve mientras aparece la marca o se cambia el knob. Es comportamiento del componente empujando al motor de movimiento con los extremos que él mismo calcula, no una prop animable, y se compone con la opacity que el slot ya declare.

Degradación

En un SDK más antiguo

En un SDK más antiguo se ven las dos mitades del control a la vez —la marca y la caja vacía, una al lado de la otra— y tocar cualquiera de ellas no hace nada.

Como Container, y este merece la pena imaginárselo: LOS DOS slots del indicador están en el layout, más la etiqueta, porque no hay nada escondiendo uno de ellos. El control está inerte. Todo lo que escribió su autor está en pantalla; lo que falta es la regla de que solo uno de los dos pinta ahí.

Sound effects

Sound effects

Cómo oye esto el juego

El binding lleva el valor; la action lleva el momento. Un toggle habla por las dos patas a la vez, y cada una responde a una pregunta distinta. El binding es el booleano: el SDK escribe el nuevo en el almacén de datos, y todo lo que esté leyendo ese path vuelve a maquetarse. La action es el evento —esto ha cambiado, ahora— y, como toda action en la v1, no lleva valor propio. Una pantalla de ajustes que guarda al salir solo necesita el binding; una que aplica al instante depende de la action.

En el otro sentido está SetChecked(id, checked), y no es un empujón al estado: recorre el mismo camino que el dedo del jugador, así que dispara onChange —y el del grupo, si está dentro de uno— exactamente igual que un toque.

using UnityEngine;
using Zabloo;

[RequireComponent(typeof(ZablooDocument))]
public sealed class Settings : MonoBehaviour
{
  [SerializeField] bool _sfx = true;

  ZablooDocument _doc;

  // Start, not OnEnable: it runs after ZablooDocument has built the view.
  void Start()
  {
      _doc = GetComponent<ZablooDocument>();
      _doc.OnAction += OnZablooAction;

      // The game owns the flag; the checkbox reads it through the binding.
      // SetData is cached, so pushing it before the node exists is fine.
      _doc.SetData("settings.sfx", _sfx);
  }

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

  void OnZablooAction(string action)
  {
      if (action != "sfx-changed") return;

      // A standalone checkbox reports every change, and every change is a flip.
      _sfx = !_sfx;
      ApplySfx(_sfx);
  }
}

Cuando la action se dispara desde dentro de un item de Repeat lleva un action context —el path, la key y el índice del item— para que el juego sepa qué fila se ha pulsado. El context es parte del formato y el renderer del navegador lo entrega; el OnAction de Unity es hoy Action<string> y entrega solo el nombre, así que un juego de Unity que necesite la fila la lee de su propio estado hasta que eso aterrice.

Composición

Cuatro nombres, un nodo. Lo que cambia de uno a otro es qué slots construye el componente y si está dentro de un grupo.

  • <Checkbox> — para un sí o no independiente dentro de una lista de ajustes.
  • <Switch> — lo mismo, vestido de track y knob; úsalo cuando el ajuste surta efecto al momento.
  • <RadioGroup> — cuando el jugador tenga que elegir una de unas pocas opciones que caben todas en pantalla a la vez.
  • <Select> — para esa misma elección única cuando la lista sea demasiado larga para enseñarla, o el hueco demasiado justo.
// Checkbox and Switch: an independent boolean each.
<Checkbox checked={{ bind: "settings.sfx" }} onChange="sfx-changed">
<Text>Sound effects</Text>
</Checkbox>

<Switch checked={{ bind: "settings.fullscreen" }}>
<Text>Fullscreen</Text>
</Switch>

// Radio and RadioGroup: the group owns the selection, so a Radio has a value
// instead of a checked. Put onChange on the GROUP — it is the node that can
// say what was picked.
<RadioGroup value={{ bind: "settings.quality" }} onChange="quality-changed" layout={{ gap: 8 }}>
<Radio value="low"><Text>Low</Text></Radio>
<Radio value="high"><Text>High</Text></Radio>
</RadioGroup>

// Select: a Button, an Overlay anchored to it with trigger: "press", and a
// ScrollView around the same "exclusive-check" group. A composite, not a type.
<Select id="lang" value={{ bind: "settings.lang" }} onChange="lang-changed">
<Option value="es"><Text>es</Text></Option>
<Option value="en"><Text>en</Text></Option>
</Select>

El <Select> es el caso que merece leerse dos veces. Es un composite aplanado, y la razón de que pudiera serlo es que la selección es un único valor: el grupo "exclusive-check" ya existía, así que lo que el formato tuvo que ganar para tener un desplegable fue el popover — la regla de que una selección dentro de un overlay anclado lo cierra. El botón cerrado enseña el valor a través de un <Text> enlazado al mismo path, porque la IR no tiene expresiones y no hay nada con lo que buscar una etiqueta: escribe los textos que se ven como si fueran los valores, cuando son para que los lea el jugador.

Relacionado