Sergio Xalambrí
January 29, 2019

# Carrusel de elementos dinámicos con React.js

Tutorial sobre como implementar un carrusel para elementos de diferentes tamaños usando React.js

Imaginemos el siguiente caso, tenemos una lista de elementos que necesitamos mostrar de forma horizontal, estos no entran en pantalla por lo que queremos poner un carrusel para ir moviéndonos entre ellos, pero ocurre que dichos elementos varián de tamaño, algunos miden 100px de ancho, otros 300px y así.

Vamos a ver como armar un componente de React que reciba como hijos una lista de elementos y cree una paginación horizontal que permite que al llegar al último elemento en pantalla el carrusel se mueva para mostrar el siguiente grupo de elementos.

> Nota: Todo esto estás basado en un componente real que tuve que armar para un proyecto en el que estoy trabajando actualmente.

Para esto vamos a crear un componente simple de React extendiendo de
`React.Component`.

    import React, { Component } from "react";

    class Carousel extends Component {
      render() {
        return null;
      }
    }

La forma en que vamos a usar este componente va a ser la siguiente

    import React from "react";
    import { render } from "react-dom";

    import Carousel from "./carousel";

    function App() {
      return (
        <Carousel component="ul" leftPadding={100} focus={0}>
          <li>Featured</li>
          <li>Highlighted</li>
          <li>Top</li>
          <li>For You</li>
          <li>Trending</li>
          <li>Coming Soon</li>
        </Carousel>
      );
    }

    render(<App />, document.getElementById("root"));

Como vemos nuestro componente va a recibir cuatro props, el primero es el clásico `children` con la lista de elementos hijos.

El segundo es `component` este nos va a servir para indicar que tag o componente personalizado queremos usar para envolver a los elementos hijos.

El tercero es el `leftPadding`, este nos va a servir para definir un espacio que debe queda siempre a la izquierda al momento de hacer el cambio de página de esta forma los elementos de la siguiente página no van a quedar pegados al borde de la pantalla o del contenedor de nuestro carrusel.

El cuarto y último nos permite indicarle cuál es el elemento con foco actualmente, esto nos va a servir para saber en donde está parado el usuario.

Sigamos programando el componente, vamos a definir el método `render` de este.

    import React, { Component } from "react";

    class Carousel extends Component {
      render() {
        // armamos nuestro objeto con los estilos que vamos a aplicar para mover el carrusel
        const style = {
          transition: "transform 200ms linear", // agregamos una transición de 200ms linear a la propiedad transform
          transform: `translateX(-${this.state.x}px)` // aplicamos un translateX en base a un valor del state llamado x
        };

        return (
          <this.props.component
            children={this.props.children}
            style={style} // nuestro componente custom debe soportar un prop `style` para aplicar estilos inline */}
          />
        );
      }
    }

Ahora vamos a empezar a armar la lógica, vamos a definir un `componentDidUpdate` que nos permite enterarnos cuando cambia el prop `focus` y calcular la nueva posición del carrusel.

    import React, { Component } from "react";

    class Carousel extends Component {
      state = {
        x: 0
      };

      componentDidUpdate(prevProps) {
        // si los props cambiaron
        if (prevProps.focus !== this.props.focus) {
          // movemos el carrusel para la izquierda o derecha (-1 izquierda, 1 derecha)
          // ej. está en 2 y antes estaba en 1 entonces se mueve a la derecha
          this.move(this.props.focus - prevProps.focus);
        }
      }

      render() {
        // armamos nuestro objeto con los estilos que vamos a aplicar para mover el carrusel
        const style = {
          transition: "transform 200ms linear", // agregamos una transición de 200ms linear a la propiedad transform
          transform: `translateX(-${this.state.x}px)` // aplicamos un translateX en base a un valor del state llamado x
        };

        return (
          <this.props.component
            children={this.props.children}
            style={style} // nuestro componente custom debe soportar un prop `style` para aplicar estilos inline */}
          />
        );
      }
    }

Este método va a recibir el antiguo foco y va a revisar si cambió, en caso de hacerlo va a restar el foco actual menos el foco anterior, esto va a dar `-1` o `+1` dependiendo de si se movió para la izquierda (`-1`) o para la derecha (`+1`), este valor se lo vamos a pasar a un método que vamos a llamar `move` que va a recibir la dirección en la que se está moviendo. Vamos a ver como implementarlo.

    import React, { Component } from "react";

    class Carousel extends Component {
      state = {
        x: 0,
        currentPage: 1
      };

      componentDidUpdate(prevProps) {
        // si los props cambiaron
        if (prevProps.focus !== this.props.focus) {
          // movemos el carrusel para la izquierda o derecha (-1 izquierda, 1 derecha)
          // ej. está en 2 y antes estaba en 1 entonces se mueve a la derecha
          this.move(this.props.focus - prevProps.focus);
        }
      }

      move = (direction = 0) => {
        // obtenemos los tamaños de todos los elementos la primera vez
        // o los traemos de los que ya calculamos en this.sizes.
        this.sizes = this.sizes || this.calculateSizes();
        // obtenemos la página a la que pertenece el nuevo elemento
        const { page } = this.sizes[this.props.focus];
        // si la página no cambió no hacemos nada
        if (this.state.currentPage === page) return;
        // obtenemos el punto de inicio del primer elemento de la página
        const { start } = this.sizes.find(element => element.page === page);
        // actualizamos el estado
        this.setState(state => ({
          // guardamos la nueva página
          currentPage: page,
          // guardamos la nueva posición en X usando el punto de inicio menos el leftPadding
          x: start - this.props.leftPadding < 0 ? 0 : start - this.props.leftPadding
        }));
      };

      render() {
        // armamos nuestro objeto con los estilos que vamos a aplicar para mover el carrusel
        const style = {
          transition: "transform 200ms linear", // agregamos una transición de 200ms linear a la propiedad transform
          transform: `translateX(-${this.state.x}px)` // aplicamos un translateX en base a un valor del state llamado x
        };

        return (
          <this.props.component
            children={this.props.children}
            style={style} // nuestro componente custom debe soportar un prop `style` para aplicar estilos inline */}
          />
        );
      }
    }

Ya tenemos la función que se va a encargar de mover nuestro carrusel, está comentado pero veamos como funciona. Primero nos fijamos que ya calculamos los tamaños y en caso de no estar calculados llamamos al método `calculateSizes`.

Después obtenemos de nuestra lista de tamaños el elemento con el foco y de este nos traemos la página a la que pertenece (ya vamos a ver como se calcula), si la página actual (guarda en el estado) es igual que la página nueva no hacemos nada.

Luego obtenemos el primero elemento de la página y de este sacamos la posición en pixeles en las que está ubicado. Por último actualizamos el estado guardando la página actual y la posición en X en la que debe estar ubicado nuestro carrusel, este se calcula haciendo `start` menos el `leftPadding` que recibimos como props, en caso de que el resultado sea menor a 0 ponemos 0, si no el resultado (esto para que funciona le primer página).

Ahora vamos a ver como se calculan los tamaños y páginas del carrusel, acá es la lógica más pesada.

    import React, { Component, createRef } from "react";

    class Carousel extends Component {
      state = {
        x: 0,
        currentPage: 1
      };

      $carousel = createRef();

      componentDidUpdate(prevProps) {
        // si los props cambiaron
        if (prevProps.focus !== this.props.focus) {
          // movemos el carrusel para la izquierda o derecha (-1 izquierda, 1 derecha)
          // ej. está en 2 y antes estaba en 1 entonces se mueve a la derecha
          this.move(this.props.focus - prevProps.focus);
        }
      }

      calculateSizes = () => {
        // obtenemos la lista de elementos del DOM de los children
        const children = this.$carousel.current.children;
        // obtenemos el width del elemento que representa nuestro carrusel
        const pageWidth = this.$carousel.current.clientWidth;

        const { elements } = Array.from(children) // convertimos a un array
          .map(child => child.getBoundingClientRect()) // obtenemos su posición en x/y y su tamaño en width/heigh
          .map(({ x, width }) => ({
            start: x, // guardamos x como start
            width, // guardamos el width
            end: x + width // calculamos donde termina el elemento sumando x y width
          }))
          .reduce(
            (result, { end, start, width }) => {
              // calculamos la paǵina (abajo vamos a ver la explicación)
              const page = Math.ceil(
                (end + result.rest + this.props.leftPadding) / pageWidth
              );

              // devolvemos el resto de la página, la última página calculada y la lista de elementos con su página
              return {
                lastPage: result.lastPage !== page ? page : result.lastPage,
                elements: result.elements.concat({ width, start, end, page }),
                rest:
                  result.lastPage !== page
                    ? pageWidth * result.lastPage - start
                    : result.rest
              };
            },
            { rest: 0, lastPage: 1, elements: [] } // empezamos el reduce con resto 0, página 1 y sin elementos
          );

        // devolvemos la lista de elementos
        return elements;
      };

      move = (direction = 0) => {
        // obtenemos los tamaños de todos los elementos la primera vez
        // o los traemos de los que ya calculamos en this.sizes.
        this.sizes = this.sizes || this.calculateSizes();
        // obtenemos la página a la que pertenece el nuevo elemento
        const { page } = this.sizes[this.props.focus];
        // si la página no cambió no hacemos nada
        if (this.state.currentPage === page) return;
        // obtenemos el punto de inicio del primer elemento de la página
        const { start } = this.sizes.find(element => element.page === page);
        // actualizamos el estado
        this.setState(state => ({
          // guardamos la nueva página
          currentPage: page,
          // guardamos la nueva posición en X usando el punto de inicio menos el leftPadding
          x: start - this.props.leftPadding < 0 ? 0 : start - this.props.leftPadding
        }));
      };

      render() {
        // armamos nuestro objeto con los estilos que vamos a aplicar para mover el carrusel
        const style = {
          transition: "transform 200ms linear", // agregamos una transición de 200ms linear a la propiedad transform
          transform: `translateX(-${this.state.x}px)` // aplicamos un translateX en base a un valor del state llamado x
        };

        return (
          <this.props.component
            ref={this.$carousel}
            children={this.props.children}
            style={style} // nuestro componente custom debe soportar un prop `style` para aplicar estilos inline */}
          />
        );
      }
    }

Este método es más complejo, veamos como funciona paso a paso. Primero creamos una referencia a nuestro componente y lo usamos para obtener la lista de nodos del DOM hijos y su ancho. Esta lista de nodos del DOM la convertimos a un array para poder luego iterarla usando métodos de arrays.

Lo siguiente es transformar cada nodo de la lista en sus valores usando `getBoundingClientRect()`, este método de los elementos de DOM nos devuelve un objeto con las propiedades `left`, `top`, `right`, `bottom`, `x`, `y`, `width`, y `height` que nos indican el tamaño y su posición en la pantalla. De estos tomamos `x` como `start`, el `width` y sumamos ambos para calcular el `end`, esto nos indica donde empieza el elemento, su tamaño y donde termina.

Lo siguiente es calcular las páginas, para esto hacemos un `reduce` cuyo valor inicial es un objeto con las propiades `rest` con valor 0, `lastPage` con valor 1 y `elements` como un array vacío. En cada iteración de nuestro `reduce` vamos a calcular la página usando la ecuación `Math.ceil((end + result.rest + this.props.leftPadding) / pageWidth)`, esto lo que hace es suma donde termina el elemento más el resto (`rest`) de la página anterior más el `leftPadding` y lo divide por el ancho del contenedor, que sería el ancho de cada página.

Luego devolvemos un objeto con las mismas propiedades que al inicio del `reduce` calculando sus nuevos valores. Primero si `lastPage` no es igual a la página que calculamos actualizamos `lastPage`, luego a nuestra lista de elementos le concatenamos un nuevo objeto con su `width`, `start`, `end` y su `page` que calculamos. Por último calculamos el resto, este se calcula solo en caso de que la página haya cambiado y es el resultado de tomar el ancho de una página, multiplicarlo por la última página y restarle el punto de inicio del elemento.

Este resto nos sirve para que si un elemento empieza en digamos la página 1 pero termina en la página 2 entonces debe pertenecer a la página 2 ya que es la única forma de que se vea completo en la pantalla, para esto en el cálculo de la página sumamos el resto de la página actual más la posición donde termina más el `leftPadding` y si con todo esto no entra en pantalla entonces debe pertenecer a la siguiente página.

Una vez hicimos todo el cálculo obtenemos solo los elementos (su tamaño, posiciones y página) y lo devolvemos.

## Usando Hooks

Ahora que vimos como funciona vamos a migrarlo a Hooks para ver como se podría hacer de forma más moderna.

    import React, { useRef, useState, useEffect } from "react";

    function Carousel({ children, focus = 0, leftPadding = 0, component = "div" }) {
      // definimos nuestros estados
      const [x, setX] = useState(0);
      const [currentPage, setCurrentPage] = useState(1);
      // creamos refs para guardar valores que necesitamos guardar entre renders
      // pero que no se usan en la UI (no son estado)
      const $carousel = useRef(null);
      const sizes = useRef(null);
      const currentFocus = useRef(focus);

      useEffect(() => {
        // cada vez que cambio focus vamos a llamar a la función move
        move(focus - currentFocus.current);
        // y guardamos el nuevo foco
        currentFocus.current = focus;
      }, [focus]);

      function calculateSizes() {
        // obtenemos la lista de elementos del DOM de los children
        const children = $carousel.current.children;
        // obtenemos el width del elemento que representa nuestro carrusel
        const pageWidth = $carousel.current.clientWidth;

        const { elements } = Array.from(children) // convertimos a un array
          .map(child => child.getBoundingClientRect()) // obtenemos su posición en x/y y su tamaño en width/heigh
          .map(({ x, width }) => ({
            start: x, // guardamos x como start
            width, // guardamos el width
            end: x + width // calculamos donde termina el elemento sumando x y width
          }))
          .reduce(
            (result, { end, start, width }) => {
              // calculamos la paǵina (abajo vamos a ver la explicación)
              const page = Math.ceil((end + result.rest + leftPadding) / pageWidth);

              // devolvemos el resto de la página, la última página calculada y la lista de elementos con su página
              return {
                lastPage: result.lastPage !== page ? page : result.lastPage,
                elements: result.elements.concat({ width, start, end, page }),
                rest:
                  result.lastPage !== page
                    ? pageWidth * result.lastPage - start
                    : result.rest
              };
            },
            { rest: 0, lastPage: 1, elements: [] } // empezamos el reduce con resto 0, página 1 y sin elementos
          );

        // devolvemos la lista de elementos
        return elements;
      }

      function move(direction = 0) {
        // obtenemos los tamaños de todos los elementos la primera vez
        // o los traemos de los que ya calculamos en this.sizes.
        sizes.current = sizes.current || calculateSizes();
        // obtenemos la página a la que pertenece el nuevo elemento
        const { page } = sizes.current[focus];
        // si la página no cambió no hacemos nada
        if (currentPage === page) return;
        // obtenemos el punto de inicio del primer elemento de la página
        const { start } = sizes.current.find(element => element.page === page);
        // actualizamos el estado
        setCurrentPage(page);
        setX(start - leftPadding < 0 ? 0 : start - leftPadding);
      }

      // armamos nuestro objeto con los estilos que vamos a aplicar para mover el carrusel
      const style = {
        transition: "transform 200ms linear", // agregamos una transición de 200ms linear a la propiedad transform
        transform: `translateX(-${x}px)` // aplicamos un translateX en base a un valor del state llamado x
      };

      const Component = component;
      return (
        <Component
          ref={$carousel}
          children={children}
          style={style} // nuestro componente custom debe soportar un prop `style` para aplicar estilos inline */}
        />
      );
    }

Como vemos el código queda más corto y un poco más simple. Es importante recordar que hooks todavía no es estable y para probarlo necesitanse necesita instalar react y react-dom usando `react@next` y `react-dom@next`.

## Palabras finales

Con esto acabamos de implementar nuestro carrusel para hijos de diferentes tamaños, parece algo complicado pero la lógica es bastante simple. A este carrusel aún podemos agregarle mejores, como soportar `rtl` o volverlo más accesible.

Por último acá debajo pueden ver como queda funcionando (la versión con clases).