끝이 뷰로 들어오면 항목을 더 불러오기
스크롤 리스너 없는 가로 무한 스크롤. 메뉴는 이미 어떤 항목이 보이는지 알므로, "사용자가 끝에 도달했나"는 그냥 질문입니다 — 마지막 항목이 화면에 있나?
onUpdate가 스크롤마다 묻고, 답이 예이면 다음 배치를 추가합니다.오른쪽 끝으로 스크롤 — 로더 카드가 나타나고 다음 배치가 도착합니다. 데모는 30개 항목에서 멈춥니다.
동작 방식
onUpdate는 항목 가시성이 변할 때마다 발화합니다. 핸들러는 api.items.last()?.visible을 읽습니다 — 라이브러리가 모든 항목을 itemId로 추적하고 항목별 가시성 플래그를 유지하므로, 끝 감지는 조회 한 번이면 되고 자체 IntersectionObserver도 스크롤 위치 계산도 없습니다. pushNewItems는 페치를 흉내 냅니다. 1초 타임아웃, 항목 5개 추가, 끝입니다.
페치 지키기
가시성 갱신은 몰려서 도착하므로, 핸들러는 반복 호출에 안전해야 합니다. loading 플래그가 멱등하게 만듭니다. onUpdate와 pushNewItems 둘 다 그것을 확인하고, 첫 트리거만 페치를 시작합니다. 같은 플래그가 Loader 컴포넌트를 실제 메뉴 항목(자체 itemId 포함)으로 렌더링하고, 마운트 시 scrollIntoView()를 호출해 배치가 로드되는 동안 행의 끝을 뷰에 유지합니다.
참고
- 오른쪽 화살표는 요소로 전달됩니다.
RightArrow={<RightArrow disabled={...} />}— 컴포넌트와 요소 형태 둘 다 동작하고, 요소 형태는 부모가 항목 상한 같은 프로퍼티를 넘길 수 있게 합니다. - 그 화살표는 상한에 도달하고 마지막 항목이 보일 때만 비활성화됩니다 — 상한 전에는 끝에 도달한다는 것은 항목이 더 온다는 뜻입니다.
newItemsLimit은 이 데모를 24개 항목에서 멈춥니다. 실제 코드에서 대응하는 신호는 API가 페이지를 소진하는 것입니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 Storybook 버전.
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();
}
};