끝이 뷰로 들어오면 항목을 더 불러오기

스크롤 리스너 없는 가로 무한 스크롤. 메뉴는 이미 어떤 항목이 보이는지 알므로, "사용자가 끝에 도달했나"는 그냥 질문입니다 — 마지막 항목이 화면에 있나? onUpdate가 스크롤마다 묻고, 답이 예이면 다음 배치를 추가합니다.

오른쪽 끝으로 스크롤 — 로더 카드가 나타나고 다음 배치가 도착합니다. 데모는 30개 항목에서 멈춥니다.

동작 방식

onUpdate는 항목 가시성이 변할 때마다 발화합니다. 핸들러는 api.items.last()?.visible을 읽습니다 — 라이브러리가 모든 항목을 itemId로 추적하고 항목별 가시성 플래그를 유지하므로, 끝 감지는 조회 한 번이면 되고 자체 IntersectionObserver도 스크롤 위치 계산도 없습니다. pushNewItems는 페치를 흉내 냅니다. 1초 타임아웃, 항목 5개 추가, 끝입니다.

페치 지키기

가시성 갱신은 몰려서 도착하므로, 핸들러는 반복 호출에 안전해야 합니다. loading 플래그가 멱등하게 만듭니다. onUpdatepushNewItems 둘 다 그것을 확인하고, 첫 트리거만 페치를 시작합니다. 같은 플래그가 Loader 컴포넌트를 실제 메뉴 항목(자체 itemId 포함)으로 렌더링하고, 마운트 시 scrollIntoView()를 호출해 배치가 로드되는 동안 행의 끝을 뷰에 유지합니다.

참고

전체 소스

완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 Storybook 버전.

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

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

export function AddItemsExample() {
  const [items, setItems] = React.useState(() => getItems());
  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 newItemsLimit = 24;
  const [loading, setLoading] = React.useState(false);
  const pushNewItems = () => {
    if (loading || items.length > newItemsLimit) {
      return false;
    }

    setLoading(true);
    const newItems = items.concat(
      Array(5)
        .fill(0)
        .map((_, ind) => ({ id: getId(items.length + ind) })),
    );
    new Promise((res) => {
      setTimeout(() => res(setItems(newItems)), 1000);
    }).finally(() => setLoading(false));
  };

  return (
    <div>
      <div>
        <NoScrollbar>
          <ScrollMenu
            LeftArrow={LeftArrow}
            RightArrow={<RightArrow disabled={items.length >= newItemsLimit} />}
            onWheel={onWheel}
            onUpdate={(api) => {
              const lastVisible = api?.items?.last?.()?.visible;
              if (lastVisible && !loading) {
                pushNewItems();
              }
            }}
          >
            {items.map(({ id }) => (
              <Card
                title={id}
                itemId={id} // NOTE: itemId is required for track items
                key={id}
                onClick={() => handleItemClick(id)}
                selected={isItemSelected(id)}
              />
            ))}
            {loading && <Loader itemId="loader" key="loader" />}
          </ScrollMenu>
        </NoScrollbar>
      </div>
    </div>
  );
}

function Loader({ itemId }: { itemId: string }) {
  const ref = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    ref.current?.scrollIntoView();
  }, [ref]);
  return <div ref={ref}>Loading...</div>;
}

export default AddItemsExample;

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({ disabled }: { disabled: boolean }) {
  const visibility = React.useContext<publicApiType>(VisibilityContext);
  const isLastItemVisible = visibility.useIsVisible('last', false);

  return (
    <Arrow
      disabled={disabled && isLastItemVisible}
      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 = () =>
  Array(10)
    .fill(0)
    .map((_, ind) => ({ id: getId(ind) }));

const onWheel = (apiObj: publicApiType, ev: React.WheelEvent) => {
  // 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();
  }
};

관련 예제

예제 전체 21개