1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-04-30

動作

  • メニューのクリックで特定の位置までスクロールする
  • スクロール位置によってメニューの選択状態が追従する
    anchor-scroll-demo-1.gif

リポジトリ

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

useAnchorScroll

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

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を使います。

  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を使います。こちらの記事がとても参考になります。

  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をラップしてもう少し使いやすくしました。

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

サンプル

素朴な使用例です。

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のこと、なんて呼べばいいの??笑
命名にも困る。。。

参考文献

1
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?