스크롤 위치 저장과 복원

가로 레일은 언마운트할 때마다 오프셋을 잊습니다. 경로를 떠났다 돌아오거나, 섹션을 접으면 시작 지점으로 튕깁니다. 이 예제는 사용자가 스크롤할 때 오프셋을 저장하고 마운트 시 다시 써서, 메뉴가 떠난 자리에 정확히 다시 나타나게 합니다.
#01
#02
#03
#04
#05
#06
#07
#08
#09
#10
#11
#12
#13
#14

행을 어딘가로 스크롤하고 메뉴를 언마운트한 뒤 다시 마운트하세요 — 레일이 같은 오프셋으로 돌아옵니다.

동작 방식

기능 전체는 두 콜백이 짊어집니다. onUpdate는 사용자가 스크롤하며 가시성 상태가 변할 때 발화하고, savePosapi.scrollContainer.current.scrollLeft를 읽어 sessionStorage에 씁니다. 다음 마운트에서 onInit이 저장값을 그대로 scrollLeft에 대입합니다 — 평범한 프로퍼티 쓰기라 복원은 사용자 앞에서 애니메이션을 재생하는 대신 즉각적입니다.

재마운트, 새로고침, 뒤로가기 탐색을 견디기

sessionStorage는 컴포넌트보다 오래 삽니다. 클라이언트 사이드 경로 변경, 조건부 렌더링, 전체 페이지 새로고침 모두 저장된 오프셋으로 돌아오고, 값은 탭별이라 두 탭이 서로 덮어쓰지 않습니다. 히스토리 탐색을 위해 스토리는 window.history.scrollRestoration = ’manual’도 설정해, 뒤로/앞으로에서 브라우저 자체의 스크롤 복원이 수동 복원과 싸우지 않게 합니다.

참고

전체 소스

완전하고 복사-붙여넣기 가능 — 이것이 바로 그 파일입니다. 출처는, 이 라이브 편집 가능한 Storybook 버전.

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

관련 예제

예제 전체 21개