Ẩn mũi tên trên di động — cuộn chỉ bằng cảm ứng trên màn hình nhỏ
Mở trên điện thoại, hoặc bật giả lập cảm ứng trong DevTools — các mũi tên biến mất và vuốt làm toàn bộ công việc.
Chỉnh sửa ví dụ này trực tiếp trong Storybook
Demo ẩn mũi tên thế nào
LeftArrow và RightArrow là các prop tùy chọn — truyền undefined và slot không được render chút nào, nên không có gì để ẩn bằng CSS và không còn nút nào trong thứ tự tab. Công tắc là một kiểm tra matchMedia(’(pointer: coarse)’) trong một effect: server không thể biết loại con trỏ, nên lần vẽ đầu là desktop-first với mũi tên, và hydration gỡ chúng khi một con trỏ thô được xác nhận. Một listener change giữ nó sống — giả lập thiết bị của DevTools lật nó không cần tải lại.
Story làm gì khi chạm
Hook useSwipe của story biến pan tự do thành phân trang. Các prop được curry onTouchStart, onTouchMove và onTouchEnd mỗi cái nhận đối tượng API; start đặt lại tọa độ cuối và ghi targetTouches[0].clientX, move theo dõi nó, và end đo khoảng cách đã đi. Quá minSwipeDistance (20px) nó gọi apiObj.scrollPrev() hoặc apiObj.scrollNext() — một trang mượt mỗi lần vuốt, bất kể tốc độ ngón tay.
Chặn cuộn cảm ứng gốc
Để phân trang là chuyển động duy nhất, việc pan của trình duyệt phải dừng, và React 18+ đăng ký listener touchmove là passive, nơi preventDefault bị bỏ qua. Effect của story với tới phần tử cuộn thật qua apiRef (ref.current.scrollContainer.current) và gắn listener riêng với { passive: false }, nơi lời gọi hoạt động.
Ghi chú
- Chọn mặc định SSR có chủ đích: render mũi tên trước có lợi cho crawler và người dùng desktop, và thiết bị cảm ứng mất chúng ngay sau hydration.
(pointer: coarse)nhắm tới đầu vào, không phải kích thước màn hình — một cửa sổ desktop hẹp giữ mũi tên, máy tính bảng thì không.- Nếu bạn chỉ muốn ẩn mũi tên và giữ vuốt gốc (hành vi của demo), bỏ effect
touchmovecủa story — pan tự do và mũi tên ẩn cùng tồn tại tốt. - Ngưỡng cảm ứng là 20px so với 50px của cú hất desktop — xem ví dụ swipe-on-desktop cho biến thể chuột.
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.
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 MobileSwipeOnly() {
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 { onTouchEnd, onTouchMove, onTouchStart } = 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}
onTouchEnd={onTouchEnd}
onTouchMove={onTouchMove}
onTouchStart={onTouchStart}
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 MobileSwipeOnly;
export const useSwipe = () => {
const [touchStart, setTouchStart] = React.useState(0);
const [touchEnd, setTouchEnd] = React.useState(0);
// the required distance between touchStart and touchEnd to be detected as a swipe
const minSwipeDistance = 20;
const onTouchStart = () => (ev: React.TouchEvent) => {
setTouchEnd(0);
setTouchStart(ev.targetTouches[0].clientX);
};
const onTouchMove = () => (ev: React.TouchEvent) => {
setTouchEnd(ev.targetTouches[0].clientX);
};
const onTouchEnd = (apiObj: publicApiType) => () => {
if (!touchStart || !touchEnd) return false;
const distance = touchStart - touchEnd;
const isSwipe = Math.abs(distance) > minSwipeDistance;
const isLeftSwipe = distance < minSwipeDistance;
if (isSwipe) {
if (isLeftSwipe) {
apiObj.scrollPrev();
} else {
apiObj.scrollNext();
}
}
};
return { onTouchStart, onTouchEnd, onTouchMove };
};
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(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();
}
}