Lưu và khôi phục vị trí cuộn

Một ray ngang quên độ lệch của nó mỗi lần unmount: rời route rồi quay lại, thu gọn một phần, và nó nhảy về đầu. Ví dụ này lưu độ lệch khi người dùng cuộn và ghi lại khi mount, để menu xuất hiện lại đúng nơi họ đã rời đi.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12
#13
#14

Cuộn hàng đến đâu đó, unmount menu, rồi mount lại — ray quay lại đúng độ lệch.

Cách hoạt động

Hai callback gánh toàn bộ tính năng. onUpdate kích hoạt khi trạng thái hiển thị của menu thay đổi trong lúc người dùng cuộn; savePos đọc api.scrollContainer.current.scrollLeft và ghi vào sessionStorage. Ở lần mount kế tiếp, onInit gán giá trị đã lưu thẳng lại scrollLeft — một lần ghi thuộc tính thường, nên việc khôi phục là tức thì thay vì một hoạt ảnh phát lại trước mặt người dùng.

Sống sót qua remount, tải lại và điều hướng quay lại

sessionStorage sống lâu hơn component: đổi route phía client, render có điều kiện và tải lại toàn trang đều quay về độ lệch đã lưu, và giá trị theo từng tab, nên hai tab không ghi đè nhau. Với điều hướng lịch sử, story còn đặt window.history.scrollRestoration = ’manual’, ngăn việc khôi phục cuộn của trình duyệt tranh chấp với cái thủ công khi quay lại và tiến.

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.

Position.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 Position() {
  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 { getPosition, setPosition, reset } = usePosition();
  const savePos = React.useCallback(
    (api: publicApiType) => {
      setPosition(api.scrollContainer.current?.scrollLeft ?? 0);
    },
    [setPosition],
  );
  const restorePosition = React.useCallback(
    (api: publicApiType) => {
      const node = api.scrollContainer.current;

      if (node) {
        node.scrollLeft = getPosition();
      }
    },
    [getPosition],
  );

  const [key, setKey] = React.useState(() => String(Math.random()));
  const reload = React.useCallback(() => setKey(String(Math.random())), []);

  return (
    <>
      <ScrollMenu
        LeftArrow={LeftArrow}
        RightArrow={RightArrow}
        onWheel={onWheel}
        onUpdate={savePos}
        onInit={restorePosition}
        key={key}
      >
        {items.map(({ id }) => (
          <Card
            title={id}
            itemId={id} // NOTE: itemId is required for track items
            key={id}
            onClick={() => handleItemClick(id)}
            selected={isItemSelected(id)}
          />
        ))}
      </ScrollMenu>
      <div>
        <button onClick={reset} data-testid="reset">
          Reset position
        </button>
        <button onClick={reload} data-testid="reload">
          Reload
        </button>
      </div>
    </>
  );
}

const usePosition = () => {
  React.useEffect(() => {
    window.history.scrollRestoration = 'manual';
  }, []);

  const setPosition = React.useCallback((pos: number | string) => {
    sessionStorage.setItem('position', String(pos));
  }, []);
  const getPosition = () => +(sessionStorage.getItem('position') || 0);
  const reset = React.useCallback(
    () => sessionStorage.removeItem('position'),
    [],
  );

  return { getPosition, setPosition, reset };
};

export default Position;

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ụ