Carrusel de elementos dinámicos con 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 trabajé junto a Kattya Cuevas.

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).

<iframe src="https://codesandbox.io/embed/93yzmvzw0w?autoresize=1&hidenavigation=1" width="100%" height="500px" style="border:0;"

</iframe>