Ẩn mũi tên trên di động — cuộn chỉ bằng cảm ứng trên màn hình nhỏ

Trên màn hình cảm ứng, các nút mũi tên là trọng lượng chết: vuốt là gốc, ngón cái che mục tiêu chạm, và mỗi mũi tên ăn chiều rộng hàng. Demo giữ mũi tên cho người dùng chuột và unmount chúng khi con trỏ là ngón tay; story đi xa hơn và thay pan gốc bằng cử chỉ vuốt-để-phân-trang tường minh.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12
#13
#14

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.

Demo ẩn mũi tên thế nào

LeftArrowRightArrow 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, onTouchMoveonTouchEnd 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ú

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.

MobileSwipeOnly.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 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();
  }
}

Các ví dụ liên quan

Tất cả 21 ví dụ