アズマオオズアリの頭部とキーボードを模したアイコンとjonnityという文字

モーダル表示時の「戻る」動作について

個人開発 ブログ

2024/1/14


皆さんは『モーダルを消そうと思って「戻る」をしたら意図せず前の画面に戻ってしまった』ことはありますか?私はあります。 このブログでも上記のことが起こるようになっており、直したので、それについてまとめます。

問題点

冒頭で説明したシチュエーションを体験したことのない人のために問題点を説明します。 そんなやつはどうせiPhoneでも使ってやがるんだろうと思います。

というのも、Android端末をよく使う人にはわかってもらえると思うのですが、Androidの「戻る」ボタン/ジェスチャーで、モーダルを消せる気がするんですよね。 そのとき、単にページ内の状態管理だけで実装しているモーダルだと、当然前の画面に戻ってしまいます。

なので、「戻る」と↓こうなっちゃっていました。

余談ですが、そう思うと、「戻る」に対してはiPhoneの「画面の左端からスワイプする」って動作のほうが直感的なのかもしれないですね。 あとPCだと、Escapeキーで消そうとして消えないこともあります。Escapeは何も起きないだけなのでましですが、消えてほしい。

対応方法

このブログでは、この記事の公開と同時に「戻る」で消えるようにしました (該当の変更はこのPR)。

まず、主に想定しているのはAndroidの「戻る」ボタン/ジェスチャーですが、これはブラウザ側での「戻る」実行になってしまうので、Webサイトで直接イベントとして拾うことはできないようです。 なので、単にブラウザの「戻る」でモーダルが消えるようにする必要があります。 そのために、モーダルを開くときにブラウザの履歴に「クエリパラメータ付きの同ページ」を追加するようにしました (↓な感じ)。

ImageViewer.tsxの抜粋
1const openModal = useCallback(() => {
2  setIsModalTarget(true);
3  const params = new URLSearchParams(currentSearchParams.toString());
4  params.set(showModalParamKey, showModalValue);
5  router.push(pathname + "?" + params.toString(), { scroll: false });
6}, [router, pathname, currentSearchParams]);

その上で、「戻る」を検知してモーダルを閉じるようにしています (↓な感じ)。

ImageViewer.tsxの抜粋
1useEffect(() => {
2  if (!hasShowModalParam) {
3    setIsModalTarget(false);
4  }
5}, [hasShowModalParam]);

ちなみに、popStateイベントでsetIsModalTarget(false)を実行しても同じことはできました。

1useEffect(() => {
2    const popStateListener = () => {
3      setIsModalTarget(false);
4    };
5    window.addEventListener("popstate", popStateListener);
6    return () => {
7      window.removeEventListener("popstate", popStateListener);
8    };
9  }, []);

なお、モーダルを開くときに追加しているクエリパラメータは、具体的には"sm=1"という意味のない値で、どの画像をモーダルとして開くかは各コンポーネント内に状態を持つことで管理しています。 それに起因して、このブログでは、『「戻る」で拡大画像を消した後、「進む」をしても再表示できない』ようになっています。 クエリパラメータを各画像のID情報を持たせたりすれば、そのような逆操作も可能にできるとは思いますが、できないほうが直感的かなと思いそうしています。 モーダルは半ばウィンドウのようなものなので、その再表示にはモーダルを開くときの操作 (今回なら画像をクリック) が改めて必要なほうが、他の物の挙動に似るのでいいのではないでしょうか。

最終的なImageViewer.tsxの全体像は↓のようになりました。

/src/util/entry/components/ImageViewer.tsx
1"use client";
2import { usePathname, useRouter, useSearchParams } from "next/navigation";
3import { useCallback, useEffect, useState } from "react";
4
5const hiddenScrollbarClassName = "hidden-scrollbar";
6const showModalParamKey = "sm";
7const showModalValue = "1";
8
9const ImageModal: React.FC<{
10  src: string;
11  alt: string;
12}> = ({ src, alt }) => {
13  const router = useRouter();
14  useEffect(() => {
15    const escapeKeyListener = (event: KeyboardEvent) => {
16      if (event.key === "Escape") {
17        router.back();
18      }
19    };
20    document.addEventListener("keydown", escapeKeyListener);
21    document.body.classList.add(hiddenScrollbarClassName);
22    return () => {
23      document.removeEventListener("keydown", escapeKeyListener);
24      document.body.classList.remove(hiddenScrollbarClassName);
25    };
26  }, [router]);
27
28  return (
29    <div
30      className="absolute left-0 top-0 flex h-dvh w-dvw justify-center bg-black bg-opacity-70 p-2 hover:cursor-zoom-out lg:p-8"
31      style={{ top: window.scrollY }}
32      onClick={() => router.back()}
33    >
34      <img src={src} alt={alt} className="object-scale-down" />
35    </div>
36  );
37};
38
39type Prop = { src: string; alt: string; caption: string };
40export const ImageViewer: React.FC<Prop> = ({ src, alt, caption }) => {
41  const router = useRouter();
42  const pathname = usePathname();
43  const currentSearchParams = useSearchParams();
44  const [isModalTarget, setIsModalTarget] = useState<boolean>(false);
45  const hasShowModalParam =
46    currentSearchParams.get(showModalParamKey) === showModalValue;
47
48  useEffect(() => {
49    if (!hasShowModalParam) {
50      setIsModalTarget(false);
51    }
52  }, [hasShowModalParam]);
53
54  const openModal = useCallback(() => {
55    setIsModalTarget(true);
56    const params = new URLSearchParams(currentSearchParams.toString());
57    params.set(showModalParamKey, showModalValue);
58    router.push(pathname + "?" + params.toString(), { scroll: false });
59  }, [router, pathname, currentSearchParams]);
60
61  return (
62    <>
63      <p className="flex h-fit flex-col items-center">
64        <img
65          className="max-h-96 max-w-full object-contain hover:cursor-zoom-in"
66          src={src}
67          alt={alt}
68          onClick={openModal}
69        />
70        <span>{caption}</span>
71      </p>
72      {hasShowModalParam && isModalTarget && <ImageModal src={src} alt={alt} />}
73    </>
74  );
75};

{hasShowModalParam && isModalTarget && <ImageModal src={src} alt={alt} />}hasShowModalParam &&は不要ですね…。hasShowModalParamfalseのときは必ずisModalTargetfalseになるので…。

まとめ

  • Androidの「戻る」ボタン/ジェスチャーはモーダルを閉じれる気がしてしまう
    • そのときに何もしていないとそのまま前のページに戻ってしまう
  • ↑を防ぐためには、モーダルを開くときに履歴に何かを追加する必要がある
  • その上で「戻る」を検知してモーダルを閉じるといい感じ
    • そのとき「進む」で再表示されないほうが直感的な気がする

PC/iOSでは「戻る」でモーダルを消そうとすることはないかもしれませんが、ぜひAndroidユーザーのために、モーダルは「戻る」で消せるようにしてください。