마우스로 드래그해 스크롤 — 클릭을 깨지 않고
터치 사용자는 가로 목록을 네이티브로 스크롤하지만, 마우스 사용자는 배선이 필요합니다. 누르고, 드래그하고, 놓기. 어려운 부분은 행을 움직이는 게 아닙니다. 순진한 구현이 드래그를 놓을 때마다 우발적 항목 클릭으로 바꾼다는 점입니다. 이 예제는 작은
DragDealer 클래스와 세 개의 마우스 프로퍼티로 둘을 분리합니다.행의 아무 곳이나 잡고 드래그하세요. 항목은 여전히 클릭 가능합니다 — 드래그 뒤의 클릭은 억제됩니다.
동작 방식
ScrollMenu는 커링된 마우스 핸들러를 노출합니다 — onMouseDown, onMouseUp, onMouseMove는 각각 API 객체를 받아 일반 이벤트 핸들러를 반환합니다. DragDealer 인스턴스는 앵커 좌표 하나를 추적합니다. 이동할 때마다 델타를 곧장 scrollContainer.current.scrollLeft에 적용합니다. 나머지는 네이티브 스크롤이 합니다 — 트랜스폼도, 물리도 없고, 스크롤바는 실제로 남습니다.
클릭이 계속 동작하는 이유
드래그는 포인터가 5px를 넘게 움직인 뒤에야 시작되므로, 평범한 클릭은 절대 스크롤하지 않습니다. 반대 방향은 고전적인 버그입니다. 항목의 onClick은 mouseup 뒤에 발화하므로, 카드 위에서 드래그를 놓으면 선택됩니다. dragStop은 applying 플래그를 즉시 지우지만 dragging은 한 애니메이션 프레임 더 유지합니다 — 클릭 핸들러가 이를 확인하고 빠져나옵니다.
가져갈 가치가 있는 세부
dragStart는 이전 제스처의 보류 중인 리셋을 취소합니다 — 없으면 빠른 두 번째 드래그가 낡은 델타를 적용할 수 있습니다.- 래퍼의
onMouseLeave도dragStop을 호출하므로, 드래그 중 행을 떠나도 드래그 상태에 갇히지 않습니다. - 터치는 이 중 어느 것도 필요 없습니다 — 컨테이너가 실제 스크롤 컨테이너라 스와이프는 이미 동작합니다.
전체 소스
완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 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 MouseDrag() {
const [items] = React.useState(() => getItems());
const [selected, setSelected] = React.useState<string[]>([]);
// NOTE: for drag by mouse
const dragState = React.useRef(new DragDealer());
const handleDrag =
({ scrollContainer }: publicApiType) =>
(ev: React.MouseEvent) =>
dragState.current.dragMove(ev, (posDiff) => {
if (scrollContainer.current) {
scrollContainer.current.scrollLeft += posDiff;
}
});
const onMouseDown = React.useCallback(
() => dragState.current.dragStart,
[dragState],
);
const onMouseUp = React.useCallback(
() => dragState.current.dragStop,
[dragState],
);
const isItemSelected = (id: string): boolean =>
!!selected.find((el) => el === id);
const handleItemClick = (itemId: string) => {
if (dragState.current.dragging) {
return false;
}
const itemSelected = isItemSelected(itemId);
setSelected((currentSelected: string[]) =>
itemSelected
? currentSelected.filter((el) => el !== itemId)
: currentSelected.concat(itemId),
);
};
return (
<NoScrollbar onMouseLeave={() => dragState.current.dragStop()}>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onMouseDown={onMouseDown}
onMouseUp={onMouseUp}
onMouseMove={handleDrag}
onWheel={onWheel}
>
{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 MouseDrag;
class DragDealer {
clicked: boolean;
dragging: boolean;
position: number;
constructor() {
this.clicked = false;
this.dragging = false;
this.position = 0;
}
public dragStart = (ev: React.MouseEvent) => {
this.position = ev.clientX;
this.clicked = true;
};
public dragStop = () => {
window.requestAnimationFrame(() => {
this.dragging = false;
this.clicked = false;
});
};
public dragMove = (ev: React.MouseEvent, cb: (posDiff: number) => void) => {
const newDiff = this.position - ev.clientX;
const movedEnough = Math.abs(newDiff) > 5;
if (this.clicked && movedEnough) {
this.dragging = true;
}
if (this.dragging && movedEnough) {
this.position = ev.clientX;
cb(newDiff);
}
};
}
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 = () =>
Array(10)
.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();
}
}