Hoạt ảnh mục vào, ra và vào đúng chỗ với auto-animate

Thêm vào một danh sách ngang khiến mục mới bật ra đúng chỗ; xóa một mục khiến hàng xóm chụp lại gần nhau. @formkit/auto-animate sửa cả hai bằng một ref cha duy nhất — và prop containerRef của ScrollMenu trao đúng phần tử nó cần.
item 1
item 2
item 3
item 4
item 5
item 6
item 7
item 8
item 9
item 10
item 11
item 12

Thêm, xóa và trộn — mỗi lần vào, ra và sắp xếp lại đều được hoạt ảnh. Bản thân menu không có code hoạt ảnh.

Cách hoạt động

useAutoAnimate() trả một ref phải đặt lên cha trực tiếp của các phần tử cần hoạt ảnh. Trong ScrollMenu cha đó là container cuộn: mỗi con bạn truyền được bọc trong một div mục, và các div mục đó là con trực tiếp của container. Story truyền ref xuyên suốt — <ScrollMenu containerRef={parent}> — và auto-animate tiếp quản từ đó: mục thêm vào ease in, mục xóa hoạt ảnh ra, và mục sắp xếp lại trượt về slot mới. Bản thân menu không bao giờ biết nó đang bị hoạt ảnh.

Thêm, xóa, trộn

Ba điều khiển đều là các lời gọi setState thường trên mảng items — addItems thêm một, removeItems bỏ cái cuối, shuffle là một lượt Fisher–Yates trên bản sao. Các hoạt ảnh hoàn toàn đến từ các đột biến DOM mà những cập nhật đó gây ra. Một quy tắc đáng giữ: itemId kiêm vai key React và vai chốt của mục trong bản đồ theo dõi của menu, nên id phải giữ duy nhất — story thậm chí lấp lại khoảng trống đánh số do xóa để lại thay vì liều đúc ra một bản trùng.

Cuộn và theo dõi tiếp tục hoạt động

Menu quan sát lại các con mỗi khi chúng đổi, nên useIsVisible của một mục mới thêm báo đúng ngay và các mũi tên tiếp tục phân trang. Tuy nhiên, mục mới thường hạ cánh ngoài màn hình — nếu màn vào phải thực sự được thấy, ghép cái này với scrollToItem như ví dụ add-item-and-scroll-to-it làm.

Ghi chú

Nguồn đầy đủ

Đầy đủ và sẵn sàng sao chép-dán — đây là file chính xác phía sau phiên bản Storybook có thể chỉnh sửa trực tiếp.

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();
  }
}

Các ví dụ liên quan

Tất cả 21 ví dụ