Animar elementos al entrar, salir y a su sitio con auto-animate

Añadir a una lista horizontal hace que el nuevo elemento aparezca de golpe; eliminar uno junta a sus vecinos de golpe. @formkit/auto-animate arregla ambas cosas con una sola ref padre — y la prop containerRef de ScrollMenu le entrega exactamente el elemento que necesita.
item 1
item 2
item 3
item 4
item 5
item 6
item 7
item 8
item 9
item 10
item 11
item 12

Añade, elimina y baraja — cada entrada, salida y reordenación está animada. El propio menú no tiene código de animación.

Cómo funciona

useAutoAnimate() devuelve una ref que debe caer sobre el padre directo de los elementos que debe animar. Dentro de ScrollMenu ese padre es el contenedor de desplazamiento: cada hijo que pasas se envuelve en un div de elemento, y esos divs de elemento son los hijos inmediatos del contenedor. La historia pasa la ref de largo — <ScrollMenu containerRef={parent}> — y auto-animate toma el relevo: los elementos añadidos se atenúan al entrar, los eliminados se animan al salir y los reordenados se deslizan a su nueva ranura. El propio menú nunca sabe que lo están animando.

Añadir, eliminar, barajar

Los tres controles son llamadas setState corrientes sobre el array items — addItems añade uno, removeItems suelta el último, shuffle es una pasada de Fisher–Yates sobre una copia. Las animaciones provienen enteramente de las mutaciones de DOM que causan esas actualizaciones. Vale la pena mantener una regla: itemId hace las veces de key de React y de manejador del elemento en el mapa de seguimiento del menú, así que los id deben seguir siendo únicos — la historia incluso rellena los huecos de numeración que dejan las eliminaciones en lugar de arriesgarse a acuñar un duplicado.

El desplazamiento y el seguimiento siguen funcionando

El menú vuelve a observar a sus hijos siempre que cambian, así que el useIsVisible de un elemento recién añadido informa correctamente de inmediato y las flechas siguen paginando. Un elemento nuevo suele aterrizar fuera de pantalla, eso sí — si la entrada debe verse de verdad, combina esto con scrollToItem como hace el ejemplo add-item-and-scroll-to-it.

Notas

Fuente completa

Completa y lista para copiar y pegar: este es el archivo exacto que está detrás de la versión de Storybook editable en vivo.

Items_animation.source.tsx
import 'react-horizontal-scrolling-menu/dist/styles.css';

import styled from '@emotion/styled';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import React from 'react';
import {
  type publicApiType,
  ScrollMenu,
  VisibilityContext,
} from 'react-horizontal-scrolling-menu';

export function ItemsAnimation() {
  const [parent] = useAutoAnimate();

  const [items, setItems] = React.useState(() => getItems(10));
  const [selected, setSelected] = React.useState<string[]>([]);

  const isItemSelected = (id: string): boolean =>
    !!selected.find((el) => el === id);

  const handleItemClick = (itemId: string) => {
    const itemSelected = isItemSelected(itemId);

    setSelected((currentSelected: string[]) =>
      itemSelected
        ? currentSelected.filter((el) => el !== itemId)
        : currentSelected.concat(itemId),
    );
  };

  const addItems = React.useCallback(() => {
    setItems((curr) => {
      const currentItemsNumbers = curr
        .map((el) => +el.id.replace(/\D/g, ''))
        .sort();
      const lastSeqItem = currentItemsNumbers.find(
        (el, ind, arr) => arr[ind + 1] - el > 1,
      );
      const haveGaps = typeof lastSeqItem === 'number';

      return [
        ...curr,
        ...getItems(1, haveGaps ? lastSeqItem + 1 : curr.length),
      ];
    });
  }, []);

  const removeItems = React.useCallback(() => {
    setItems((curr) => [...curr.sort().slice(0, curr.length - 1)]);
  }, []);

  const shuffle = React.useCallback(() => {
    setItems((curr) => {
      const array = [...curr];
      let currentIndex = array.length,
        randomIndex;

      while (currentIndex > 0) {
        randomIndex = Math.floor(Math.random() * currentIndex);
        currentIndex--;

        [array[currentIndex], array[randomIndex]] = [
          array[randomIndex],
          array[currentIndex],
        ];
      }

      return array;
    });
  }, []);

  return (
    <NoScrollbar>
      <ScrollMenu
        containerRef={parent}
        LeftArrow={LeftArrow}
        RightArrow={RightArrow}
        onWheel={onWheel}
        noPolyfill={false}
      >
        {items.map(({ id }) => (
          <Card
            title={id}
            itemId={id} // NOTE: itemId is required for track items
            key={id}
            onClick={() => handleItemClick(id)}
            selected={isItemSelected(id)}
          />
        ))}
      </ScrollMenu>
      <div style={{ display: 'flex', gap: '8px', margin: '8px' }}>
        <button onClick={addItems}>Add item</button>
        <button onClick={removeItems}>Remove item</button>
        <button onClick={shuffle}>Shuffle items</button>
      </div>
    </NoScrollbar>
  );
}

export default ItemsAnimation;

const NoScrollbar = styled('div')({
  '& .react-horizontal-scrolling-menu--scroll-container::-webkit-scrollbar': {
    display: 'none',
  },
  '& .react-horizontal-scrolling-menu--scroll-container': {
    scrollbarWidth: 'none',
    '-ms-overflow-style': 'none',
  },
});

function LeftArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);

  const disabled = visibility.useLeftArrowVisible();

  return (
    <Arrow
      disabled={disabled}
      onClick={() => visibility.scrollPrev()}
      testId="left-arrow"
    >
      Left
    </Arrow>
  );
}

function RightArrow() {
  const visibility = React.useContext<publicApiType>(VisibilityContext);

  const disabled = visibility.useRightArrowVisible();

  return (
    <Arrow
      disabled={disabled}
      onClick={() => visibility.scrollNext()}
      testId="right-arrow"
    >
      Right
    </Arrow>
  );
}

function Arrow({
  children,
  disabled,
  onClick,
  className,
  testId,
}: {
  children: React.ReactNode;
  disabled: boolean;
  onClick: VoidFunction;
  className?: string;
  testId: string;
}) {
  return (
    <ArrowButton
      disabled={disabled}
      onClick={onClick}
      className={'arrow' + `-${className}`}
      data-testid={testId}
    >
      {children}
    </ArrowButton>
  );
}
const ArrowButton = styled('button')((props) => ({
  cursor: 'pointer',
  display: 'flex',
  flexDirection: 'column',
  justifyContent: 'center',
  marginBottom: '2px',
  opacity: props.disabled ? '0' : '1',
  userSelect: 'none',
  borderRadius: '6px',
  borderWidth: '1px',
}));

function Card({
  onClick,
  selected,
  title,
  itemId,
}: {
  onClick: (context: publicApiType) => void;
  selected: boolean;
  title: string;
  itemId: string;
}) {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isVisible = visibility.useIsVisible(itemId, true);

  return (
    <CardBody
      data-cy={itemId}
      onClick={() => onClick(visibility)}
      onKeyDown={(ev: React.KeyboardEvent) => {
        ev.code === 'Enter' && onClick(visibility);
      }}
      data-testid="card"
      role="button"
      tabIndex={0}
      className="card"
      visible={isVisible}
      selected={selected}
    >
      <div className="header">
        <div>{title}</div>
        <div className="visible">visible: {JSON.stringify(isVisible)}</div>
        <div className="selected">selected: {JSON.stringify(!!selected)}</div>
      </div>
      <div className="background" />
    </CardBody>
  );
}
const CardBody = styled('div')<{ selected?: boolean; visible?: boolean }>(
  (props) => ({
    border: '1px solid',
    display: 'inline-block',
    margin: '0 10px',
    width: '160px',
    userSelect: 'none',
    borderRadius: '8px',
    overflow: 'hidden',

    '& .header': {
      backgroundColor: 'white',
    },

    '& .visible': {
      backgroundColor: props.visible ? 'transparent' : 'gray',
    },

    '& .background': {
      backgroundColor: props.selected ? 'green' : 'bisque',
      height: '200px',
    },
  }),
);

const getId = (index: number) => `${'test'}${index}`;

const getItems = (count: number, start: number = 0) =>
  Array(count)
    .fill(0)
    .map((_, ind) => ({ id: getId(start + ind) }));

function onWheel(apiObj: publicApiType, ev: React.WheelEvent): void {
  // NOTE: no good standart way to distinguish touchpad scrolling gestures
  // but can assume that gesture will affect X axis, mouse scroll only Y axis
  // of if deltaY too small probably is it touchpad
  const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15;

  if (isThouchpad) {
    ev.stopPropagation();
    return;
  }

  if (ev.deltaY < 0) {
    apiObj.scrollNext();
  } else {
    apiObj.scrollPrev();
  }
}

Ejemplos relacionados

Todos los ejemplos (21)