데스크톱에서 스와이프: 메뉴를 페이지 넘기는 마우스 플릭
scrollNext나 scrollPrev로 그 방향으로 한 페이지 미끄러집니다. 행은 포인터를 전혀 따라가지 않습니다. 미끄러짐은 라이브러리의 스무스한 프로그램적 스크롤이며, 그것이 놓는 순간에 관성감을 줍니다.행의 아무 곳이나 누르고 왼쪽 또는 오른쪽으로 최소 50px 움직인 뒤 놓으세요 — 메뉴가 한 페이지 미끄러집니다. 짧은 움직임은 아무것도 하지 않습니다.
동작 방식
useSwipe 훅은 ScrollMenu가 기대하는 세 개의 커링된 마우스 프로퍼티를 반환합니다 — 각각 API 객체를 받아 일반 이벤트 핸들러를 반환합니다. onMouseDown은 포인터의 clientX를 ref에 고정하고, onMouseMove는 끝 좌표를 계속 덮어쓰고, onMouseUp은 둘을 비교합니다. minSwipeDistance(50px)를 넘는 수평 차이라면, 왼쪽 플릭에서 apiObj.scrollNext(), 오른쪽에서 apiObj.scrollPrev()를 호출합니다.
클릭에 특별한 처리가 필요 없는 이유
드래그 스크롤 예제에서는 카드 위에서 드래그를 놓으면 클릭되므로, dragging 플래그가 제스처보다 한 프레임 더 오래 살아야 했습니다. 플릭은 문제 전체를 비켜갑니다. 50px 임계값 미만에서 onMouseUp은 아무것도 하지 않으므로 클릭은 그냥 클릭입니다 — 그리고 넘어서면 포인터는 어차피 누른 카드를 떠나 있습니다. 플래그도, 억제된 핸들러도 없습니다.
스토리가 터치와 휠에 더하는 것
스토리는 네이티브 터치 패닝도 고정합니다. React 18+는 touchmove 리스너를 패시브로 등록하므로, preventDefault는 비패시브 리스너에서만 동작합니다. 이펙트가 apiRef(ref.current.scrollContainer.current)를 통해 스크롤 컨테이너에 도달해 { passive: false }로 리스너를 붙입니다. 그 onWheel 핸들러도 휴리스틱과 함께 메뉴를 페이지 넘깁니다 — 0이 아닌 deltaX나 작은 deltaY는 터치패드로 보고 내버려 둡니다.
참고
- 좌표는 state가 아니라 ref에 있습니다 — state에서
mousemove를 추적하면 픽셀마다 재렌더링됩니다. - 데모는
mousedown에서 끝 좌표를 다시 고정하므로, 이전 제스처의 남은 위치가 새 스와이프에 절대 더해지지 않습니다. minSwipeDistance는 취향껏 조정하세요. 낮을수록 더 민첩하고, 높을수록 엉성한 클릭을 허용합니다. 이 레시피의 터치 변형은 20px를 씁니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 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 SwipeDesktop() {
const [items] = 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 { onMouseDown, onMouseMove, onMouseUp } = useSwipe();
const ref = React.useRef<publicApiType>(null);
// React 18+ attaches touchmove listeners as passive, so preventDefault only
// works from a non-passive listener added manually to the scroll container.
React.useEffect(() => {
const onTouchMove = (ev: TouchEvent) => {
ev.preventDefault();
};
const node = ref.current?.scrollContainer.current;
node?.addEventListener('touchmove', onTouchMove, { passive: false });
return () => node?.removeEventListener('touchmove', onTouchMove);
}, [ref]);
return (
<NoScrollbar>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
apiRef={ref}
>
{items.map(({ id }) => (
<Card
title={id}
itemId={id} // NOTE: itemId is required for track items
key={id}
onClick={() => handleItemClick(id)}
selected={isItemSelected(id)}
/>
))}
</ScrollMenu>
</NoScrollbar>
);
}
export default SwipeDesktop;
export const useSwipe = () => {
const pos = React.useRef({ start: { x: 0, y: 0 }, end: { x: 0, y: 0 } });
// the required distance between touchStart and touchEnd to be detected as a swipe
const minSwipeDistance = 50;
const onMouseDown = () => (ev: React.MouseEvent) => {
pos.current.start = { x: ev.clientX, y: ev.clientY };
};
const onMouseMove = () => (ev: React.MouseEvent) => {
pos.current.end = { x: ev.clientX, y: ev.clientY };
};
const onMouseUp = (apiObj: publicApiType) => () => {
// disable it for native touch screen devices
// if ('ontouchstart' in window) { return false }
const horDiff = pos.current.end.x - pos.current.start.x;
// const vertDiff = pos.current.end.y - pos.current.start.y;
const toLeft = horDiff < 0 && Math.abs(horDiff) > minSwipeDistance;
const toRight = horDiff > 0 && Math.abs(horDiff) > minSwipeDistance;
// for vertical menu
// const toTop = vertDiff < 0 && Math.abs(vertDiff) > minSwipeDistance;
// const toBottom = vertDiff > 0 && Math.abs(vertDiff) > minSwipeDistance;
if (toLeft) {
apiObj.scrollNext();
}
if (toRight) {
apiObj.scrollPrev();
}
};
return { onMouseDown, onMouseMove, onMouseUp };
};
const NoScrollbar = styled('div')({
'&': {
position: 'relative',
},
'& .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 = () =>
Array(20)
.fill(0)
.map((_, ind) => ({ id: getId(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();
}
}