Arrow Navigation - Parte 2: El proceso de desarrollo
5 may 2023
La biblioteca de la que voy a hablar en estos artículos es @arrow-navigation/core y su implementación para React @arrow-navigation/react. Actualmente, en sus versiones 1.2.6 y 1.0.3 respectivamente. La demo de la biblioteca se encuentra en: https://arrow-navigation-demo.vercel.app/
Los repositorios para que puedan mirar el código son:
- @borisbelmar/arrow-navigation
- @borisbelmar/arrow-navigation-react
- @borisbelmar/arrow-navigation-demo
Agradezco cualquier feedback, estrella ⭐️ o comentario. Sin nada más que agregar, comencemos con esta serie de artículos 🤟.
Para leer el primera parte, visita este enlace Arrow Navigation - Parte 1: Creando mi primera biblioteca open source.
En el primer artículo de esta serie, exploramos los desafíos únicos que enfrenté al desarrollar aplicaciones de TV para dispositivos LG y Samsung, centrándonos en el problema de la navegación espacial y la experiencia del usuario en las interfaces de TV. Después de compartir mis experiencias y lecciones aprendidas, ahora nos adentramos en el segundo artículo, donde profundizaremos en la solución que desarrollé para abordar estos desafíos.
En esta segunda entrega, detallaré cómo optimizamos esta solución para lograr una navegación sin configuración, encontrar el nodo más cercano y discriminar candidatos. Además, mostraré cómo mejorar aún más la eficiencia de nuestro algoritmo agrupando elementos y enlazándolos, con el objetivo de alcanzar una complejidad asintótica óptima. A lo largo de este artículo, compartiré las ideas y el proceso de pensamiento detrás de cada iteración y decisión tomada al diseñar esta solución. ¡Acompáñame en este emocionante viaje para mejorar la experiencia de desarrollo en aplicaciones de TV Web!
Navegación sin complicaciones: fácil de implementar y eficiente
Una de las metas principales al desarrollar la biblioteca era garantizar su fácil implementación y que, para casos sencillos, fuera lo más inteligente posible. Para lograr esto, decidí mantener un estado singleton que debía ser inicializado al montar la aplicación en el DOM, con el objetivo de llevar un registro de los elementos enfocables. La idea detrás de esta decisión es distinguir los elementos que deseo incluir en mi lógica de navegación y descartar aquellos que no.
El enfoque consiste en registrar los elementos al montarlos y eliminarlos del registro al desmontarlos. Para ello, utilicé una estructura de tipo Map para almacenar los elementos junto con su identificador, facilitando así su manipulación en el DOM.
Además, al inicializar este estado, también añadí un detector de eventos (event listener) que escucha el evento de keydown del DOM, con el propósito de gestionar la navegación mediante la detección de las teclas direccionales.
Encontrar el nodo más cercano
Ya tenía los elementos registrados, ahora necesitaba saber cuál elemento era el más cercano. Para esto, desarrollé un algoritmo que midiera la distancia euclidiana entre el centro de todos los elementos registrados, ayudándome del método getBoundingClientRect() de los nodos del DOM. Esto me permite conocer la ubicación y tamaño del elemento en el momento exacto en que el evento de navegación es disparado. El problema de esto es que, independiente de la dirección presionada, traería el elemento más cercano, aunque este esté en la dirección contraria.
Discriminando candidatos
Ya sabía la distancia entre los nodos, pero me di cuenta de que no era necesario realizar este cálculo con todos los nodos, ya que primero debíamos discriminar los nodos candidatos según algunas reglas:
- El nodo debe intersectar en el eje de la dirección seleccionada, es decir, eje Y en caso de arriba y abajo, eje X en caso de izquierda o derecha. Para casos de interfaces más complejas, se le agrega un umbral para que la intersección sea más o menos amplia.
- El nodo debe ser visible en el viewport; en caso de no querer este comportamiento, se puede desactivar a través de opciones.
- El nodo no debe estar en estado disabled.
Si el elemento registrado cumple con estos requisitos, entonces es un candidato válido y se debe medir la distancia entre ellos, ¿o no?
Enlazando elementos para lograr una complejidad asintótica O(1)
Cuando desarrollamos una interfaz de usuario estática y controlada, sin tanto dinamismo, podemos saber a priori qué elementos se van a registrar, se van a montar y en qué orden se hará. Por ello, a los elementos enfocables se les añade la opción nextElementByDirection, que recibe un objeto plano con las 4 direcciones, permitiendo establecer su comportamiento en la navegación de antemano. A cada dirección, podemos asignarle un id de elemento al cual se hará focus al realizar un blur en la dirección estipulada, se le puede asignar null para hacer que no siga navegando desde ese elemento hacia la dirección elegida, y undefined para mantener el comportamiento por defecto de la biblioteca. Esto permite crear una especie de lista cuádruplemente enlazada, donde cada elemento apunta a diferentes elementos dependiendo de la dirección. A esto, también le aplicamos el filtro de si existe realmente el elemento o el nodo no está con estado disabled. En el caso de que no cumpla con estos filtros, si el elemento existe pero está deshabilitado, realiza esta misma acción de manera recursiva al próximo nodo hasta que no tenga establecido un nextElementByDirection o sea undefined en esa dirección. En caso de que el elemento no exista, entonces simplemente buscará el siguiente mejor candidato aplicando filtros y el cálculo de distancia euclidiana.
Optimizando aún más la algorítmica agrupando elementos
La famosa y muy utilizada Teoría de la Gestalt nos dice que tendemos a agrupar los elementos que tienen una similitud o continuidad visual en nuestra cabeza. Cuando hacemos una barra de navegación lateral a la hora de diseñar una interfaz de usuario, tendemos a encerrarla dentro de un contenedor, con el fin de crear el affordance en el usuario de que es un conjunto de acciones con una misma finalidad. Esto me llevó a darme cuenta de algo. Si tan solo registramos los elementos en el espacio, a la hora de encontrar el mejor candidato aplicando filtros y midiendo la distancia, la complejidad asintótica de esta acción, tanto en el mejor de los casos (Big Omega) como en el peor (Big O), siempre será N, es decir, llevado a la notación asintótica como tal, sería Ω(N) y O(N). Esto se debe a que debo consultar todos los elementos registrados para saber cuál es el más cercano en caso de que no lo tenga ya configurado dentro del elemento.
Para mejorar esto, lo que hice fue agrupar los elementos dentro de focusable groups, en los cuales se registrarían los elementos. Luego, a la hora de encontrar el mejor candidato, se hace primero dentro de los elementos del grupo y, si no hay candidatos en la dirección dentro del grupo, se busca el mejor grupo candidato aplicando una lógica similar, hasta llegar al mejor candidato.
Para llevar esto a algo más concreto, a la hora de aplicar este enfoque se tienen en cuenta los siguientes axiomas:
- N = Total elementos registrados
- G = Total de grupos registrados con elementos
- Gn = Total de elementos en un grupo
- N = G * Gn
- N >= G && N >= Gn
- Gn > 0
Con estos axiomas, podemos tomar en cuenta que, si bien en el peor de los casos sigue siendo O(N) - esto debido a que, aunque eliminamos los grupos sin elementos registrados de la ecuación, debemos verificar de igual manera si es que el grupo no tiene todos sus elementos con estado disabled - en el mejor de los casos solo sería Ω(Gn), lo cual es mucho mejor en interfaces donde hemos registrado muchos elementos. Para este caso, la notación asintótica para el promedio de casos (Big Theta) sería en teoría θ(Gn1 + G + Gn2), es decir, la cantidad de elementos del primer grupo sumado a la cantidad de grupos y sumado a la cantidad de elementos de un segundo grupo.
Esto, a la hora de aplicar la biblioteca sin configuración ni complejidad en la implementación alguna, podría manejar la navegación de forma eficiente.
En el siguiente artículo revisaremos un poco sobre los eventos a los que podemos suscribirnos para lograr crear lógicas complejas con la biblioteca y su implementación en React. Recuerda darle una estrella al proyecto y probarlo en la demo presentada más arriba.