Coordinación de animaciones esbeltas con XState

Esta publicación es una introducción a XState, tal como podría usarse en un proyecto Svelte . XState es único en el ecosistema de JavaScript. No mantendrá su DOM sincronizado con el estado de su aplicación, pero ayudará a administrar el estado de su aplicación al permitirle modelarla como una máquina de estados finitos (FSM).

Una inmersión profunda en las máquinas de estado y los lenguajes formales está más allá del alcance de esta publicación, pero Jon Bellah lo hace en otro artículo de CSS-Tricks . Por ahora, piense en un FSM como un diagrama de flujo. Los diagramas de flujo tienen una serie de estados, representados como burbujas y flechas que van de un estado al siguiente, lo que significa una transición de un estado al siguiente. Las máquinas de estados pueden tener más de una flecha que salga de un estado, o ninguna si es un estado final, e incluso pueden tener flechas que salgan de un estado y apunten directamente hacia ese mismo estado.

Si todo esto le parece abrumador, relájese, entraremos en todos los detalles, de forma agradable y lenta. Por ahora, la visión de alto nivel es que, cuando modelamos nuestra aplicación como una máquina de estados, crearemos diferentes “estados” en los que puede estar nuestra aplicación (entiéndalo… máquina de estados… ¿estados?), y los eventos que suceden y provocar cambios de estado serán las flechas entre esos estados. XState llama a los estados “estados” y a las flechas entre los estados “acciones”.

Nuestro ejemplo

XState tiene una curva de aprendizaje, lo que hace que enseñar sea un desafío. Con un caso de uso demasiado artificial, parecerá innecesariamente complejo. Sólo cuando el código de una aplicación se enreda un poco es cuando XState brilla. Esto hace que escribir sobre ello sea complicado. Dicho esto, el ejemplo que veremos es un widget de autocompletar (a veces llamado sugerencia automática), o un cuadro de entrada que, al hacer clic, revela una lista de elementos para elegir, que se filtran a medida que escribe la entrada.

Para esta publicación veremos cómo limpiar el código de la animación. Aquí está el punto de partida:

Este es el código real de mi biblioteca de svelte-helpers , aunque se eliminaron piezas innecesarias para esta publicación. Puede hacer clic en la entrada y filtrar los elementos, pero no podrá seleccionar nada, “flecha hacia abajo” a través de los elementos, desplazarse, etc. Eliminé todo el código que es irrelevante para esta publicación.

Veremos la animación de la lista de elementos. Cuando haces clic en la entrada y la lista de resultados se muestra por primera vez, queremos animarla hacia abajo. A medida que escribe y filtra, los cambios en las dimensiones de la lista se animarán cada vez más. Y cuando la entrada pierde el foco, o haces clic en ESC, animamos la altura de la lista a cero, mientras la desvanecemos y luego la eliminamos del DOM (y no antes). Para hacer las cosas más interesantes (y agradables para el usuario), usemos una configuración de resorte diferente para la apertura que la que usamos para el cierre, de modo que la lista se cierre un poco más rápido o más rígido, de modo que la UX innecesaria no se demore. la pantalla demasiado tiempo.

Si se pregunta por qué no estoy usando transiciones Svelte para administrar las animaciones dentro y fuera del DOM, es porque también estoy animando las dimensiones de la lista cuando está abierta, a medida que el usuario filtra y coordinando entre transición y normal. Las animaciones de primavera son mucho más difíciles que simplemente esperar a que una actualización de primavera termine de llegar a cero antes de eliminar un elemento del DOM. Por ejemplo, ¿qué sucede si el usuario escribe y filtra rápidamente la lista mientras se anima? Como veremos, XState facilita las transiciones de estado complicadas como ésta.

Determinar el alcance del problema

Echemos un vistazo al código del ejemplo hasta ahora . Tenemos una openvariable para controlar cuándo está abierta la lista y una resultsListVisiblepropiedad para controlar si debe estar en el DOM. También tenemos una closingvariable que controla si la lista está en proceso de cierre.

En la línea 28, hay un inputEngagedmétodo que se ejecuta cuando se hace clic o se enfoca la entrada. Por ahora, observemos que se establece openy resultsListVisiblees verdadero. inputChangedSe llama cuando el usuario escribe la entrada y se establece openen verdadero. Esto es para cuando la entrada está enfocada, el usuario hace clic en Escape para cerrarla, pero luego comienza a escribir para poder volver a abrirla. Y, por supuesto, la inputBlurredfunción se ejecuta cuando se espera y se establece closingen verdadero y openfalso.

Separemos este lío y veamos cómo funcionan las animaciones. Tenga en cuenta el slideInSpringy opacitySpringen la parte superior. El primero desliza la lista hacia arriba y hacia abajo y ajusta el tamaño a medida que el usuario escribe. Este último desvanece la lista cuando está oculto. Nos centraremos principalmente en slideInSpring.

Eche un vistazo a la monstruosidad de una función llamada setSpringDimensions. Esto actualiza nuestro resorte deslizante. Centrándonos en las partes importantes, tomamos algunas propiedades booleanas. Si la lista se está abriendo, configuramos la configuración del resorte de apertura, configuramos inmediatamente el ancho de la lista (quiero que la lista solo se deslice hacia abajo, no hacia abajo y hacia afuera), a través de la { hard: true }configuración, y luego configuramos la altura. Si cerramos, animamos a cero y, cuando la animación se completa, la configuramos resultsListVisibleen falso (si se interrumpe la animación de cierre, Svelte será lo suficientemente inteligente como para no resolver la promesa, por lo que la devolución de llamada nunca se ejecutará). Por último, este método también se llama cada vez que cambia el tamaño de la lista de resultados, es decir, cuando el usuario filtra. Creamos un ResizeObserverotro lugar para gestionar esto.

Espaguetis en abundancia

Hagamos un balance de este código.

  • Tenemos nuestra openvariable que rastrea si la lista está abierta.
  • Tenemos la resultsListVisiblevariable que rastrea si la lista debe estar en el DOM (y establecerse en falso después de que se complete la animación de cierre).
  • Tenemos la closingvariable que rastrea si la lista está en proceso de cerrarse, lo cual verificamos en el controlador de enfoque/clic de entrada para que podamos revertir la animación de cierre si el usuario vuelve a activar rápidamente el widget antes de que termine de cerrarse.
  • También tenemos setSpringDimensionsque llamamos en cuatro lugares diferentes. Establece nuestros resortes dependiendo de si la lista se abre, se cierra o simplemente cambia de tamaño mientras está abierta (es decir, si el usuario filtra la lista).
  • Por último, tenemos una resultsListRenderedacción Svelte que se ejecuta cuando se representa el elemento DOM de la lista de resultados. Inicia nuestro ResizeObservery, cuando el nodo DOM se desmonta, se establece closingen falso.

¿Captaste el error? Cuando ESCse presiona el botón, solo lo configuro openen false. Olvidé configurar el cierre truey llamar setSpringDimensions(false, true). ¡Este error no fue creado intencionalmente para esta publicación de blog! Ese es un error real que cometí cuando estaba revisando las animaciones de este widget. Podría simplemente copiar y pegar el código inputBlureddonde se encuentra el botón de escape, o incluso moverlo a una nueva función y llamarlo desde ambos lugares. Este error no es fundamentalmente difícil de resolver, pero aumenta la carga cognitiva del código.

Hay muchas cosas de las que estamos realizando un seguimiento, pero lo peor de todo es que este estado está disperso por todo el módulo. Tome cualquier estado descrito anteriormente y use la función Buscar de CodeSandbox para ver todos los lugares donde se usa ese estado. Verás el cursor rebotando por el archivo. Ahora imagina que eres nuevo en este código y estás intentando encontrarle sentido. Piense en el modelo mental en crecimiento de todas estas piezas de estado a las que tendrá que realizar un seguimiento, descubriendo cómo funciona en función de todos los lugares en los que existe. Todos hemos estado allí; apesta. XState ofrece una mejor manera; veamos como.

Presentamos XState

Retrocedamos un poco. ¿No sería más sencillo modelar nuestro widget en términos del estado en el que se encuentra, con eventos que suceden mientras el usuario interactúa, lo que causa efectos secundarios y transiciones a nuevos estados? Por supuesto, pero eso es lo que ya estábamos haciendo; El problema es que el código está disperso por todas partes. XState nos brinda la capacidad de modelar adecuadamente nuestro estado de esta manera.

Establecer expectativas

No espere que XState haga desaparecer mágicamente toda nuestra complejidad. Todavía necesitamos coordinar nuestros resortes, ajustar la configuración del resorte según los estados de apertura y cierre, manejar cambios de tamaño, etc. Lo que XState nos brinda es la capacidad de centralizar este código de administración de estado de una manera que sea fácil de razonar y ajustar. De hecho, nuestro recuento general de líneas aumentará un poco como resultado de la configuración de nuestra máquina de estados. Vamos a ver.

Tu primera máquina de estados

Entremos y veamos cómo se ve una máquina de estado básica. Estoy usando el paquete FSM de XState, que es una versión mínima y reducida de XState, con un tamaño de paquete pequeño de 1 KB, perfecto para bibliotecas (como un widget de sugerencia automática). No tiene muchas funciones avanzadas como el paquete XState completo, pero no las necesitaríamos para nuestro caso de uso y no las querríamos para una publicación introductoria como esta.

El código de nuestra máquina de estados se encuentra a continuación y la demostración interactiva finalizó en Code Sandbox . Hay mucho, pero lo repasaremos en breve. Y para ser claros, todavía no funciona.

const stateMachine = createMachine(  {    initial: "initial",    context: {      open: false,      node: null    },    states: {      initial: {        on: { OPEN: "open" }      },      open: {        on: {          RENDERED: { actions: "rendered" },          RESIZE: { actions: "resize" },          CLOSE: "closing"        },        entry: "opened"      },      closing: {        on: {          OPEN: { target: "open", actions: ["resize"] },          CLOSED: "closed"        },        entry: "close"      },      closed: {        on: {          OPEN: "open"        },        entry: "closed"      }    }  },  {    actions: {      opened: assign(context = {        return { ...context, open: true };      }),      rendered: assign((context, evt) = {        const { node } = evt;        return { ...context, node };      }),      close() {},      resize(context) {},      closed: assign(() = {        return { open: false, node: null };      })    }  });

Vayamos de arriba a abajo. La initialpropiedad controla cuál es el estado inicial, al que he llamado “inicial”. contextson los datos asociados con nuestra máquina de estados. Estoy almacenando un valor booleano para saber si la lista de resultados está abierta actualmente, así como un nodeobjeto para esa misma lista de resultados. A continuación vemos nuestros estados. Cada estado es una clave en la statespropiedad. Para la mayoría de los estados, puede ver que tenemos una onpropiedad y una entrypropiedad.

onConfigura eventos. Para cada evento, podemos pasar a un nuevo estado; podemos ejecutar efectos secundarios, llamados acciones; o ambos. Por ejemplo, cuando el OPENevento ocurre dentro del initialestado, nos mudamos al openestado. Cuando el RENDEREDevento ocurre en el openestado, ejecutamos la renderedacción. Y cuando el OPENevento ocurre dentro del closingestado, hacemos la transición al openestado y también ejecutamos la acción de cambio de tamaño. El entrycampo que ve en la mayoría de los estados configura una acción para que se ejecute automáticamente cada vez que se ingresa a un estado. También hay exitacciones, aunque no las necesitamos aquí.

Todavía tenemos algunas cosas más que cubrir. Veamos cómo pueden cambiar los datos o el contexto de nuestra máquina de estados. Cuando queremos que una acción modifique el contexto, la envolvemos assigny devolvemos el nuevo contexto de nuestra acción; Si no necesitamos ningún procesamiento, podemos pasar el nuevo estado directamente a assign. Si nuestra acción no actualiza el contexto, es decir, es solo para efectos secundarios, entonces no envolvemos nuestra función de acción assigny simplemente realizamos los efectos secundarios que necesitemos.

Afectando el cambio en nuestra máquina de estados.

Tenemos un modelo genial para nuestra máquina de estados, pero ¿cómo lo ejecutamos ? Usamos la interpretfunción.

const stateMachineService = interpret(stateMachine).start();

Ahora stateMachineServiceestá nuestra máquina de estados en ejecución, en la que podemos invocar eventos para forzar nuestras transiciones y acciones. Para activar un evento, llamamos a send, pasando el nombre del evento y luego, opcionalmente, el objeto del evento. Por ejemplo, en nuestra acción Svelte que se ejecuta cuando la lista de resultados se monta por primera vez en el DOM, tenemos esto:

stateMachineService.send({ type: "RENDERED", node });

Así es como la acción renderizada obtiene el nodo de la lista de resultados. Si observa el resto del AutoComplete.sveltearchivo, verá todo el código de administración de estado ad hoc reemplazado por envíos de eventos de una sola línea. En el controlador de eventos para nuestro clic/enfoque de entrada, ejecutamos el OPENevento. Nuestro ResizeObserver activa el RESIZEevento. Etcétera.

Hagamos una pausa por un momento y apreciemos las cosas que XState nos brinda gratis aquí. Veamos el controlador que se ejecuta cuando se hace clic o se enfoca nuestra entrada antes de agregar XState.

function inputEngaged(evt) {  if (closing) {    setSpringDimensions();  }  open = true;  resultsListVisible = true;} 

Antes, estábamos comprobando si nos estábamos cerrando y, de ser así, forzando un nuevo cálculo de nuestro resorte deslizante. De lo contrario abrimos nuestro widget. Pero ¿qué pasaba si hacíamos clic en la entrada cuando ya estaba abierta? Se volvió a ejecutar el mismo código. Afortunadamente eso realmente no importó. A Svelte no le importa si restablecemos los valores openque resultsListVisibleya tenían. Pero esas preocupaciones desaparecen con XState. La nueva versión se ve así:

function inputEngaged(evt) {  stateMachineService.send("OPEN");}

Si nuestra máquina de estados ya está en estado abierto y activamos el OPENevento, entonces no sucede nada, ya que no hay OPENningún evento configurado para ese estado. ¿Y ese manejo especial para cuando se hace clic en la entrada cuando se cierran los resultados? Esto también se maneja directamente en la configuración de la máquina de estado: observe cómo el OPENevento se suma a la resizeacción cuando se ejecuta desde el closingestado.

Y, por supuesto, hemos solucionado el ESCerror clave de antes. Ahora, al presionar la tecla simplemente se activa el CLOSEevento, y eso es todo.

Terminando

El final es casi anticlimático. Necesitamos tomar todo el trabajo que estábamos haciendo antes y simplemente moverlo al lugar correcto entre nuestras acciones. XState no elimina la necesidad de que escribamos código; sólo proporciona un lugar claro y estructurado para colocarlo.

{  actions: {    opened: assign({ open: true }),    rendered: assign((context, evt) = {      const { node } = evt;      const dimensions = getResultsListDimensions(node);      itemsHeightObserver.observe(node);      opacitySpring.set(1, { hard: true });      Object.assign(slideInSpring, SLIDE_OPEN);      slideInSpring.update(prev = ({ ...prev, width: dimensions.width }), {        hard: true      });      slideInSpring.set(dimensions, { hard: false });      return { ...context, node };    }),    close() {      opacitySpring.set(0);      Object.assign(slideInSpring, SLIDE_CLOSE);      slideInSpring        .update(prev = ({ ...prev, height: 0 }))        .then(() = {          stateMachineService.send("CLOSED");        });    },    resize(context) {      opacitySpring.set(1);      slideInSpring.set(getResultsListDimensions(context.node));    },    closed: assign(() = {      itemsHeightObserver.unobserve(resultsList);      return { open: false, node: null };    })  }}

Retazos

Nuestro estado de animación está en nuestra máquina de estados, pero ¿cómo lo sacamos ? Necesitamos el openestado para controlar la representación de nuestra lista de resultados y, aunque no se usa en esta demostración, la versión real de este widget de sugerencia automática necesita el nodo DOM de la lista de resultados para cosas como desplazar el elemento actualmente resaltado a la vista.

Resulta que tenemos stateMachineServiceun subscribemétodo que se activa cada vez que hay un cambio de estado. La devolución de llamada que pasa se invoca con el estado actual de la máquina, que incluye un contextobjeto. Pero Svelte tiene un truco especial bajo la manga: su sintaxis reactiva $:no solo funciona con variables de componentes y tiendas Svelte; también funciona con cualquier objeto con un subscribemétodo. Eso significa que podemos sincronizarnos con nuestra máquina de estado con algo tan simple como esto:

$: ({ open, node: resultsList } = $stateMachineService.context);

Solo una desestructuración regular, con algunos padres para ayudar a que las cosas se analicen correctamente.

Una breve nota aquí, como área de mejora. En este momento, tenemos algunas acciones que realizan un efecto secundario y también actualizan el estado. Idealmente, probablemente deberíamos dividirlas en dos acciones, una solo para el efecto secundario y la otra assignpara el nuevo estado. Pero decidí mantener las cosas lo más simples posible en este artículo para ayudar a facilitar la introducción de XState, incluso si algunas cosas terminaron no siendo del todo ideales.

Aquí está la demostración

Pensamientos de despedida

Espero que esta publicación haya despertado algún interés en XState. Descubrí que es una herramienta increíblemente útil y fácil de usar para gestionar estados complejos. Tenga en cuenta que sólo hemos arañado la superficie. Nos centramos en el paquete fsm mínimo, pero toda la biblioteca XState es capaz de hacer mucho más de lo que cubrimos aquí, desde estados anidados hasta soporte de primera clase para Promesas, ¡e incluso tiene una herramienta de visualización de estados! Te insto a que lo compruebes.

¡Feliz codificación!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Subir