Saltar al contenido
zabloo

Input y focus

Hit-testing, enfocabilidad, navegación direccional, teclado y mando — todo lo lleva el SDK, se deriva del layout y no se autora en ninguna parte.

Esta página va de qué pasa cuando un dedo, un ratón o una cruceta se encuentran con la UI. Abre el diario de misiones con un mando: uno de los botones de seguir ya está resaltado, pulsar abajo mueve el resalte a la fila siguiente y pulsar A lo acciona. Nada de eso se autoró. No hay ningún mapa de foco en el envelope —el sobre que el juego descarga—, ninguna lista que diga qué control está al lado de cuál y ningún orden de tabulación: el SDK lo deduce de dónde han caído realmente las cajas en pantalla.

Ese es el hilo conductor: como el SDK calcula la geometría, es también dueño del hit-testing (a qué nodo pertenece un punto), del foco (qué control es ahora mismo el destino del teclado y del mando) y de la navegación direccional (a qué control lleva una dirección). Una UI se navega con mando por lo que es, no porque alguien la cableara.

El hit-testing

El hit-testing responde a una sola pregunta: el jugador ha tocado este píxel, ¿de quién es? La respuesta tiene que ser la misma en todos los targets, así que se define contra el layout y no contra lo que se pintó.

La región de entrada de un nodo es su rect de layout. No su geometría pintada, y por eso el formato mantiene el invariante de que nada pinta fuera de su rect: si no, las dos cosas no coincidirían, y esa discrepancia sería invisible.

El orden de resolución de un punto:

  1. La capa de overlays, de arriba abajo. Los overlays se prueban antes que el árbol, en orden inverso de capa. Un overlay modal captura el punto: todo lo que hay debajo —incluidos los overlays inferiores— queda inalcanzable, que es lo que hace que un backdrop sea un backdrop. Un overlay no modal solo se queda el punto si cae sobre alguno de sus hijos.
  2. El árbol, primero el nodo más profundo, y entre hermanos ganan los posteriores a los anteriores, ya que pintan los últimos.

El recorte corta la entrada. El recorte efectivo de un nodo es la intersección del de todos sus ancestros, y un punto fuera de él poda el subárbol entero: una fila que ha salido scrolleando de un ScrollView ni se dibuja ni es pulsable. El desbordamiento que no está recortado sigue siendo alcanzable: solo un recorte corta la entrada, exactamente igual que solo un recorte corta el pintado.

El radio de las esquinas se respeta. Las esquinas redondeadas de un recorte cortan también la región de entrada, así que un toque en la esquina, fuera de un panel redondeado, se cuela hasta lo que hay detrás.

Una vez alcanzado un nodo, el evento se atribuye al ancestro enfocable más cercano: un toque sobre el Text que hay dentro de un Button es un toque en el Button.

Los gestos cancelados

A un jugador que empieza a pulsar Comprar y se ve interrumpido no se le puede cobrar la espada. Todos los SDK tienen que estar de acuerdo en eso, así que lo que hace un gesto que se corta es parte del contrato y no la suposición de cada target.

normative

Un puntero no siempre acaba en una soltada. Un toque puede interrumpirlo el sistema, un gesto del navegador puede llevarse el puntero, un dispositivo puede desconectarse a media pulsación. Todos los gestos en vuelo terminan, y ninguno concluye:

En vueloAl cancelar
Una pulsación sobre un Button o un ToggleSe suelta sin activar — ni acción, ni movimiento de valor.
Un arrastre sobre un ScrollViewSe para; el offset al que llegó se queda.
Un arrastre de selección en un TextInputSe para; la selección a la que llegó se queda.
Una pulsación sobre el backdrop de un modalNo se cierra nada.
Un arrastre de SliderSe asienta: se dispara onCommit.

El Slider es la única excepción porque su valor ya está en pantalla y se escribió en su ruta bindeada en cada movimiento: negarle el commit dejaría al juego sin el evento de «aplica lo caro» para un valor que el jugador dejó ahí de verdad. Los demás no habían producido nada todavía, así que producirlo ahora sería inventarse una intención.

Esta es la misma regla que la de una pulsación soltada fuera de su control, y se aplica al mando: un mando desconectado a media pulsación la cancela, y un Slider que estuviera moviendo se asienta.

La enfocabilidad

Enfocable significa que el teclado y el mando pueden aterrizar ahí: un control que el jugador puede alcanzar y accionar. No es algo que se encienda: sale de lo que el nodo es, así que el mismo tipo de nodo es enfocable en cualquier UI y en cualquier target. Eso es lo que hace navegable una pantalla sin que nadie la audite.

La enfocabilidad se deriva de la identidad del componente. No hay una prop focusable:

normativeEnfocabilidad por tipo de nodo

Tipo de nodoEnfocable
Button
Toggle
Slider
TextInput
CollapseLo es su cabecera (children[0]); el Collapse en sí, no.
Container, Text, ImageNo
ScrollViewNo — scrollear es un gesto, no la interacción propia de un control.
OverlayNo — es una capa, y los candidatos son sus hijos.
Repeat, ProgressBar, SpinnerNo

El hover enciende exactamente ese mismo conjunto. Lo que acepta entrada es lo que puede verse distinto bajo el puntero, así que un puntero sobre un Container a secas no pone nada en hover, y un ratón y un mando ven el mismo conjunto de controles vivos. Esto es lo que permite que un tooltip disparado por hover llegue a un mando sin un segundo mecanismo: en un mando, el foco es el hover.

disabled

Apagar una sección de la UI —una fila de tienda que el jugador no puede pagar, un grupo de ajustes que necesita antes una casilla— tiene que apagar todo lo que lleva dentro, y tiene que hacerlo igual en todas partes. Así que disabled se escribe aquí entero: una prop, y todo lo que se sigue de ella.

normative

disabled es la única excepción a la regla de arriba: un nodo que lo lleva no es enfocable, sea del tipo que sea.

{ "type": "Container", "disabled": { "bind": "settings.custom" }, "children": [] }

Se hereda. El valor efectivo de un nodo es su propio disabled o el de cualquier ancestro, igual que un recorte es la intersección del de todos sus ancestros. Una sola prop en una sección apaga por tanto el formulario que lleva dentro —que es el caso para el que existe— y un Overlay reinicia la cadena, porque la entrada de una capa es la cima de su propio ámbito de entrada: un modal declarado dentro de un panel deshabilitado sigue siendo operable, y sigue pudiéndose cerrar.

Todo lo que el modelo de interacción hace con él se sigue de estar fuera del conjunto de enfocables:

  • Ni foco, ni hover. No es candidato de navegación, así que las flechas y la cruceta pasan de largo, y el puntero no enciende nada.
  • Ni pulsación, ni acción. Ni un toque, ni Enter, ni la A del mando lo activan, así que no se dispara ninguna named action y no se mueve ningún valor. Una pulsación que caiga encima se cuela hasta lo que haya detrás: un Button muerto dentro de un ScrollView no se traga el arrastre que scrollea la lista.
  • Lo que tuviera, lo suelta. Un control que el juego deshabilita mientras tiene el foco, el hover o un dedo encima pierde las tres cosas. El foco se va a nada y no a un vecino —el jugador no ha pedido moverse— y un gesto de Slider en vuelo se cancela, no se confirma: el valor nunca se asentó.
  • Una sección deshabilitada se sigue pudiendo leer. Scrollear no es una interacción de la que sea dueño un control, así que un ScrollView que haya dentro sigue funcionando. Un jugador que no puede usar un panel tiene que poder leerlo igualmente.
  • El juego no se queda bloqueado. El canal del host va fuera de banda, como un SetData sobre una ruta bindeada: SetValue y SetScroll siguen llegando a un nodo deshabilitado. Lo que disabled describe es lo que puede hacer el jugador.

Por sí solo no da estilo a nada. No hay ningún look atenuado de serie, exactamente igual que con cualquier otro estado: el aspecto de un control deshabilitado es states.disabled.style, y un nodo sin sobrescritura declarada pinta igual que siempre. Ver Style › Los estados.

El foco inicial

autofocus: true marca el nodo que toma el foco cuando se abre su ámbito. Gana el primero en orden de documento.

Un popover es la excepción: se abre sobre su selección —la opción marcada del grupo que lleva dentro, para que una lista de veinte idiomas se abra donde el jugador la dejó—, y si no la hay cae al autofocus del subárbol, y después a su primer enfocable. Un menú que el jugador ha abierto es un menú en el que está, y uno que empezara sin foco no se podría recorrer con las flechas. La lista además scrollea hasta donde sea que aterrice.

La navegación direccional

El jugador pulsa abajo en la cruceta. ¿Qué control se enciende a continuación? Todos los SDK tienen que responder eso igual, o la misma pantalla se recorrería distinto en una consola que en un móvil — así que la respuesta es una regla aritmética sobre los rectángulos, no una heurística que se invente cada target.

normative

El foco se mueve por un eje cada vez —arriba, abajo, izquierda, derecha— y el destino se calcula a partir de los rects de layout vivos, así que sobrevive a un relayout, a un hot-update o a un Collapse que acaba de cambiar la forma de la pantalla.

Para una dirección (dx, dy) y el nodo que tiene ahora el foco, sobre cada candidato enfocable del ámbito actual:

  1. Toma el vector entre los centros de los dos rects: (deltaX, deltaY).
  2. projection = deltaX·dx + deltaY·dy — cuánto queda el candidato en la dirección del movimiento. Un candidato con projection <= 0.5 se descarta: no está hacia ese lado.
  3. orthogonal = |deltaX·dy| + |deltaY·dx| — cuánto se sale del eje.
  4. score = projection + orthogonal · 2. Gana la puntuación más baja.

Ejemplo resuelto — bajar por el diario de misiones. El foco está en el botón de seguir de la primera misión y el jugador pulsa abajo, así que (dx, dy) es (0, 1). El botón de seguir de una fila más abajo tiene su centro 72 px por debajo y perfectamente en línea: projection es 72, orthogonal es 0, puntúa 72. El de dos filas más abajo puntúa 144. Y un enlace de «ver todo» que está 40 px por debajo pero 200 px a la izquierda puntúa 40 + 200·2 = 440, así que pierde contra la fila de abajo aunque físicamente esté más cerca.

Pesar el doble la distancia ortogonal es lo que hace que gane el control más cercano en esa dirección frente a uno algo más próximo pero que está sobre todo de lado: el comportamiento que un jugador espera de una UI de consola.

Sin ningún foco, una dirección toma el autofocus del ámbito, o su primer enfocable.

Revelado automático. Cuando el foco aterriza dentro de un ScrollView, el scroller lo trae a la vista. Y burbujea: cada scroller revela el hijo propio que contiene el foco. Sin eso, la navegación se metería en filas que están fuera de la vista, y un mando no tiene rueda con la que ir a buscarlas.

El ámbito de foco y la trampa

Un ámbito de foco es el conjunto de controles a los que se le permite llegar a una dirección. Importa sobre todo por un caso: cuando hay un diálogo de confirmación levantado, la cruceta no puede irse a los botones de detrás.

El ámbito es normalmente la view entera. Mientras hay un overlay modal levantado, el ámbito es el subárbol de ese overlay: la navegación no puede salir de él, ni puede alcanzar nada que haya detrás.

La trampa se deriva de modal: no hay un campo aparte para ella. Es la misma propiedad que hace que el overlay capture la entrada del puntero, porque son la misma afirmación: esto es lo único con lo que puedes interactuar ahora mismo. Un overlay no modal —un aviso, un tooltip— no atrapa nada, y sus propios botones son candidatos ordinarios del ámbito de la view.

Cerrar un overlay devuelve el foco a quien lo tuviera antes.

El foco en una lista virtualizada

Una lista larga no construye todas las filas, solo las que el viewport puede enseñar. Así que scrollear puede borrar justo el nodo que tiene el foco, y lo que pase entonces es la diferencia entre una lista que se siente sólida con un mando y otra que pierde el sitio del jugador. Es normativo porque los dos targets tienen que perderlo y recuperarlo idénticamente.

normative

Un Repeat solo materializa las filas que su viewport puede enseñar, así que scrollear una lista destruye la fila que tiene el foco. Eso es el renderer reciclando un nodo, no el jugador cediendo el foco, y las dos cosas no se pueden confundir: el foco pasa a ser lógico y se recuerda como el elemento sobre el que estaba — la lista, la identidad del elemento y en qué parte de la fila estaba.

Mientras la fila no está materializada:

  • Nadie lleva el foco puesto. Ningún nodo pinta enfocado, y pulsar el botón de activación no hace nada: no hay ningún control en pantalla que activar.
  • El foco no se regala. Nunca cae de vuelta al autofocus de la view. Un foco que salta al otro extremo de la pantalla porque el jugador ha scrolleado una lista es un fallo, y la rueda, un arrastre y el stick derecho lo producen los tres.
  • La lista sigue scrolleando. El stick sigue moviendo el ScrollView en el que estaba el foco, así que el gesto que echó la fila de la vista no se corta a la mitad.

La fila recupera el foco cuando se vuelve a materializar, sobre el mismo nodo — por identidad, así que con una key sigue al elemento a través de una reordenación. Dos cosas terminan la espera en vez de eso: una dirección, porque el jugador ha pedido moverse y no hay rect desde el que hacerlo, así que el recorrido vuelve a empezar desde el autofocus del ámbito; y cualquier decisión de foco real — un toque, un modal que se abre, el juego.

El teclado

Una UI de juego se juega con mando, pero se construye y se prueba con teclado, y los dos no pueden separarse. Cada tecla de abajo resuelve a una intención que el mando también produce, y por eso el mapeo es fijo y no por juego.

normativeTeclado

TeclaEfecto
FlechasMueven el foco, según el algoritmo de arriba.
EnterActiva el nodo con el foco — acciona un Button, conmuta un Toggle, envía un TextInput.
EspacioPulsa el nodo con el foco, exactamente como Enter — se hunde al bajar la tecla, se activa al soltarla. Dentro de un TextInput es un carácter.
EscapePetición de cierre al overlay modal más alto.
Teclas de textoEditan el TextInput con el foco.

Enter y Espacio son una sola intención, y es la A del mando: la pulsación enciende el estado pressed al bajar y activa al subir, así que una soltada que no llega —el foco se movió, el mando se desconectó— cancela en vez de disparar. La repetición automática se ignora: mantener la tecla no activa dos veces.

Mientras un TextInput tiene el foco

Escribir en un campo y navegar por una pantalla quieren las mismas teclas, y un campo de texto tiene que ganar las que editan texto sin tragarse las que lo abandonan. El reparto de abajo es exacto por esa razón.

Un campo con el foco reclama las teclas que lo editan antes de que las vea nada de lo de arriba; lo que no reclama se cuela hasta el tratamiento ordinario.

normativeTeclas que reclama un TextInput con el foco

TeclaEfecto
← →Mueven el cursor un carácter; con una selección levantada, lo colapsan contra el borde al que se empujó, en vez de avanzar además. Con Shift, extienden la selección. Sin nada seleccionado y con el cursor ya en ese extremo, el campo devuelve la tecla y el foco lo abandona.
Inicio / FinCursor al principio o al final de la línea. Con Shift, seleccionan hasta ahí.
Cmd/Ctrl + ← →Igual que Inicio/Fin — y con el mismo comportamiento con Shift.
Cmd/Ctrl + ASelecciona el valor entero.
Retroceso / SuprimirBorran la selección cuando la hay; si no, un carácter, hacia atrás o hacia delante.
EnterDispara onSubmit. Nunca inserta un salto de línea: en v1 el campo es de una línea.
EspacioUn carácter, así que no pulsa nada.
TabSe traga. Aquí la navegación es espacial, así que un Tab solo entregaría el teclado a lo que la página tenga a continuación y dejaría el campo con pinta de seguir teniendo el foco.
↑ ↓No las reclama — un campo es de una línea, así que las flechas verticales navegan fuera de él como en cualquier otro sitio.
Cmd/Ctrl + C / X / V / ZNo se interceptan — las ejecuta el campo propio de la plataforma y la edición vuelve por su evento de entrada.

Dos controles reclaman las flechas para sí, y lo hacen distinto a propósito:

  • Un Slider se queda las flechas de su propio eje y no las devuelve nunca. Las del eje cruzado siguen navegando: recorrer un rango es para lo que están esas teclas mientras un slider tiene el foco.
  • Un TextInput se queda las flechas para mover su cursor, pero las devuelve en los extremos: al final del texto, una pulsación más abandona el campo. Salir de una cadena larga a golpe de tecla no es un precio razonable, y un campo de texto no es sitio para quedarse atrapado.

La navegación es espacial, así que no hay ningún orden de tabulación que implementar.

El mando

El mando es una fuente de entrada más, no un segundo modelo de entrada. Todo lo que lee resuelve a una intención que el teclado ya produce, y los dos pasan por los mismos manejadores — que es lo que impide que «navegar con la cruceta» y «navegar con las flechas» se separen.

Que es también por lo que el mapeo es normativo: un jugador que pase del mando al teclado a mitad de sesión no puede encontrarse otra UI.

normativeMando

ControlIntención
Cruceta / stick izquierdoUna dirección unitaria — el movimiento propio de las flechas.
APulsa/activa el nodo con el foco (Enter). Soltarla suelta la pulsación.
BPetición de cierre al modal más alto (Escape).
Stick derechoScrollea el ScrollView en el que vive el foco.

Valores de referencia, compartidos por todos los targets (gamepad.ts): zona muerta de navegación 0.5 con soltada en 0.35 —el hueco es histéresis, ya que un stick que descansa sobre el umbral no puede dispararse repetidamente sin moverse—, zona muerta de scroll 0.15, repetición al mantener tras 400 ms a un paso cada 90 ms, y una velocidad de scroll de 1100 px/s a plena deflexión.

El gesto de cancelación del puntero se aplica también al mando: una pulsación que termina fuera del control, mando desconectado incluido, cancela en vez de activar.

De quién son las teclas

Una view rara vez está sola en pantalla. En el editor convive con barras de herramientas y paneles; en la web comparte página con HTML corriente. En toda esta sección el host es esa aplicación de alrededor —la página, el editor, la propia interfaz del motor—, que es cosa distinta del juego cuya UI es la view. Cuando el jugador pulsa Enter y las dos partes podrían reclamarlo con razón, esto es lo que decide.

normative

El puntero está acotado a su propia superficie por construcción. El teclado y el mando no lo están —las teclas son un evento de página y el mando es un dispositivo de toda la página—, así que quién los lee son dos preguntas, y se hacen en este orden.

El foco propio del host va primero

Cada control propio del host —una barra de herramientas, un panel, un campo de texto— tiene derecho a las teclas mientras tenga el foco. El renderer lee una tecla solo cuando el foco del host está sobre la view misma, o sobre nada. Con cualquier otra cosa no solo se niega a actuar: tampoco puede consumir la tecla, porque suprimirla es justo lo que impediría al host convertir ese Enter en una pulsación del botón que tiene el foco.

«Sobre la view misma» cubre dos cosas: la superficie sobre la que dibuja la view, y el elemento editable a través del que escribe la plataforma en los targets que necesitan uno — el campo oculto del renderer web, que tiene que vivir fuera del canvas porque un canvas no puede componer IME. Un TextInput con el foco es exactamente el caso en el que las teclas llegan legítimamente con otra cosa enfocada.

La superficie es enfocable (tabindex en la web), así que el foco puede entrar y salir de la view igual que entra y sale de cualquier otro control, y pulsarla se lleva el foco además de la entrada.

Y después, qué view

Cuando el foco no apunta a ninguna view en particular, puede haber más de una montada, y exactamente una de ellas es dueña de la entrada:

  • La primera view montada es la dueña, así que una página con una sola view se comporta como si la regla no existiera.
  • Tocar una view se la entrega: una pulsación en cualquier punto de su superficie, caiga donde caiga. Pulsar sobre nada en concreto sigue siendo usar esa view.
  • Enfocar la superficie de una view también se la entrega, así que las dos preguntas no pueden apuntar nunca a views distintas.
  • Destruir a la dueña se la entrega a la view más antigua que quede, y suelta el foco del host si lo tenía.

La propiedad de la entrada no se deriva del foco del host, y por eso es una pregunta aparte: una view en la que nadie ha hecho clic todavía, en una página cuyo foco está sobre nada, sigue leyendo el teclado.

Todo lo demás se queda por view. Cada una conserva su propio foco, su propio hover y sus propios offsets de scroll — estas dos preguntas deciden quién oye las teclas y consulta el mando, no dónde vive el foco.

Lo que maneja el juego

El foco, el hover, la pulsación, el offset de scroll, el estado abierto/marcado/seleccionado y el texto de un campo son estado de ejecución del que es dueño el SDK. Nada de eso se serializa en la IR, y nada de eso sobrevive a una recarga como dato autorado. El juego llega a ello a través del canal del host, no a través del formato.

Relacionado