【React】 メニューとスクロール位置の連動を実装

### 動作

- メニューのクリックで特定の位置までスクロールする
- スクロール位置によってメニューの選択状態が追従する
![anchor-scroll-demo-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/946419/3dc95838-eced-b40d-1759-36dd20a6e4c7.gif)

### リポジトリ

https://github.com/Yoshifumi14/react-anchor-scroll

中身的にはこの記事とほぼ変わりません。コードさえ読めればこちらだけで十分かもしれません。

## `useAnchorScroll`

ロジックの本体です。使い回しが簡単になるようにカスタムフックとして切り出しています。

```ts:AnchorScrollHook.ts
import React from "react";

import { range } from "utils/ArrayUtils";

/**
 * @param elementsLength 要素の数
 * @param setSelectedIndex 要素の選択状態を更新する関数
 * @returns  onClickMenu: メニューのクリックハンドラ, scrollAreaRef: スクロールするエリアのref, elementsRefs: メニューによってスクロール位置を操作したい要素のref
 */
export function useAnchorScroll(elementsLength: number, setSelectedIndex: (index: number) => void) {
  const elementsRefs = React.useMemo(
    () => range(elementsLength).map((_) => React.createRef<HTMLDivElement>()),
    [elementsLength]
  );
  const onClickMenu = React.useCallback(
    (index: number) => {
      const element = elementsRefs[index].current;
      if (element != null) {
        element.scrollIntoView({
          behavior: "auto", // "smooth"はIntersectionObserverのコールバックがたくさん走ってしまい挙動がカクカクになってしまう。"auto"(デフォルト値)はまだマシ。
          block: "start",
        });
      }
      setSelectedIndex(index);
    },
    [elementsRefs, setSelectedIndex]
  );
  const intersectCallback = React.useCallback(
    (entries: IntersectionObserverEntry[]) => {
      entries.forEach((entry) => {
        elementsRefs.forEach((ref, index) => {
          if (ref.current != null && ref.current.isEqualNode(entry.target) && entry.isIntersecting) {
            setSelectedIndex(index);
          }
        });
      });
    },
    [elementsRefs, setSelectedIndex]
  );
  const scrollAreaRef = React.useRef<HTMLDivElement>(null);
  React.useEffect(() => {
    const observer = new IntersectionObserver(intersectCallback, {
      root: scrollAreaRef.current,
      rootMargin: "-50% 0px",
      threshold: 0,
    });
    elementsRefs.forEach((ref) => {
      if (ref.current != null) {
        observer.observe(ref.current);
      }
    });
    return () => {
      elementsRefs.forEach((ref) => {
        if (ref.current != null) {
          observer.unobserve(ref.current);
        }
      });
    };
  }, [intersectCallback, elementsRefs]);
  return { onClickMenu, scrollAreaRef, elementsRefs };
}
```

#### メニューのクリックで特定の位置までスクロールする (15~27行目)

scrollIntoViewを使います。

```ts
  const onClickMenu = React.useCallback(
    (index: number) => {
      const element = elementsRefs[index].current;
      if (element != null) {
        element.scrollIntoView({
          behavior: "auto", // "smooth"はIntersectionObserverのコールバックがたくさん走ってしまい挙動がカクカクになってしまう。"auto"(デフォルト値)はまだマシ。
          block: "start",
        });
      }
      setSelectedIndex(index);
    },
    [elementsRefs, setSelectedIndex]
  );
```

コメントに書いてありますが、`behavior`はあえて`"smooth"`ではなく`"auto"`で妥協しています。`"smooth"`でもいい感じに動作させたい。。。


#### スクロール位置によってメニューの選択状態が追従する(28~59行目)

Intersection Observerを使います。[こちらの記事](https://ics.media/entry/190902/)がとても参考になります。

```ts:
  const intersectCallback = React.useCallback(
    (entries: IntersectionObserverEntry[]) => {
      entries.forEach((entry) => {
        elementsRefs.forEach((ref, index) => {
          if (ref.current != null && ref.current.isEqualNode(entry.target) && entry.isIntersecting) {
            setSelectedIndex(index);
          }
        });
      });
    },
    [elementsRefs, setSelectedIndex]
  );
  const scrollAreaRef = React.useRef<HTMLDivElement>(null);
  React.useEffect(() => {
    const observer = new IntersectionObserver(intersectCallback, {
      root: scrollAreaRef.current,
      rootMargin: "-50% 0px",
      threshold: 0,
    });
    elementsRefs.forEach((ref) => {
      if (ref.current != null) {
        observer.observe(ref.current);
      }
    });
    return () => {
      elementsRefs.forEach((ref) => {
        if (ref.current != null) {
          observer.unobserve(ref.current);
        }
      });
    };
  }, [intersectCallback, elementsRefs]);
```

## AnchorScroll.tsx

`useAnchorScroll`をラップしてもう少し使いやすくしました。

```tsx:AnchorScroll.tsx
import React from "react";

import { useAnchorScroll } from "./AnchorScrollHook";

const Context = React.createContext<{
  elementsRefs: React.RefObject<HTMLDivElement>[];
  onClickMenu: (index: number) => void;
  scrollAreaRef: React.RefObject<HTMLDivElement>;
}>({
  elementsRefs: [],
  onClickMenu: () => {},
  scrollAreaRef: React.createRef<HTMLDivElement>(),
});

export type AnchorScrollContextProps = {
  elementsLength: number;
  setSelectedIndex: (index: number) => void;
};

export const AnchorScrollContext = React.memo<React.PropsWithChildren<AnchorScrollContextProps>>(
  function AnchorScrollContext({ elementsLength, setSelectedIndex, children }) {
    const { elementsRefs, onClickMenu, scrollAreaRef } = useAnchorScroll(elementsLength, setSelectedIndex);
    return <Context.Provider value={{ elementsRefs, onClickMenu, scrollAreaRef }}>{children}</Context.Provider>;
  }
);

type AnchorScrollAreaProps = {} & React.AreaHTMLAttributes<HTMLDivElement>;

export const AnchorScrollArea = React.memo<AnchorScrollAreaProps>(function AnchorScrollArea(props) {
  const ref = React.useContext(Context).scrollAreaRef;
  return <div {...props} ref={ref} />;
});

export type AnchorScrollElementProps = {
  index: number;
} & React.AreaHTMLAttributes<HTMLDivElement>;

export const AnchorScrollElement = React.memo<React.PropsWithChildren<AnchorScrollElementProps>>(
  function AnchorScrollElement({ index, ...otherProps }) {
    const ref = React.useContext(Context).elementsRefs[index];
    return <div {...otherProps} ref={ref} />;
  }
);

export type AnchorScrollMenusProps = {
  render: (onClickMenu: (index: number) => void) => React.ReactNode;
};

export const AnchorScrollMenus = React.memo<AnchorScrollMenusProps>(function AnchorScrollMenus({ render }) {
  const onClickMenu = React.useContext(Context).onClickMenu;
  return <>{render(onClickMenu)}</>;
});
```

### サンプル

素朴な使用例です。

```tsx:App.tsx
import React from "react";

import { AnchorScrollContext, AnchorScrollMenus, AnchorScrollArea, AnchorScrollElement } from "anchorscroll";

const App = () => {
  const [selectedIndex, setSelectedMenu] = React.useState(0);
  const elementsLength = 3;
  const toSelectedButtonStyle = React.useCallback(
    (index: number) => ({ color: selectedIndex === index ? "red" : undefined }),
    [selectedIndex]
  );
  return (
    <div style={{ display: "flex", height: "200px", margin: "16px", border: "1px solid orange" }}>
      <AnchorScrollContext elementsLength={elementsLength} setSelectedIndex={setSelectedMenu}>
        <AnchorScrollMenus
          render={(onClickMenu) => (
            <div style={{ display: "flex", flexDirection: "column", width: "80px", padding: "4px" }}>
              <button onClick={() => onClickMenu(0)} style={toSelectedButtonStyle(0)}>Item 1</button>
              <button onClick={() => onClickMenu(1)} style={toSelectedButtonStyle(1)}>Item 2</button>
              <button onClick={() => onClickMenu(2)} style={toSelectedButtonStyle(2)}>Item 3</button>
            </div>
          )}
        />
        <AnchorScrollArea style={{ width: "100%", margin: "0px 4px", overflowY: "auto" }}>
          <AnchorScrollElement index={0} style={{ height: 200 }}>
            Item 1
          </AnchorScrollElement>
          <AnchorScrollElement index={1} style={{ height: 200 }}>
            Item 2
          </AnchorScrollElement>
          <AnchorScrollElement index={2} style={{ height: 200 }}>
            Item 3
          </AnchorScrollElement>
        </AnchorScrollArea>
      </AnchorScrollContext>
    </div>
  );
};

export default App;
```

## 最後に一言

このUIのこと、なんて呼べばいいの？？笑
命名にも困る。。。

## 参考文献

- Element.scrollIntoView(): https://developer.mozilla.org/ja/docs/Web/API/Element/scrollIntoView
- 交差オブザーバー API: https://developer.mozilla.org/ja/docs/Web/API/Intersection_Observer_API
- JSでのスクロール連動エフェクトにはIntersection Observerが便利: https://ics.media/entry/190902/
- Create React App: https://create-react-app.dev/