モーダル表示時の「戻る」動作について
個人開発 ブログ
2024/1/14
皆さんは『モーダルを消そうと思って「戻る」をしたら意図せず前の画面に戻ってしまった』ことはありますか?私はあります。 このブログでも上記のことが起こるようになっており、直したので、それについてまとめます。
問題点
冒頭で説明したシチュエーションを体験したことのない人のために問題点を説明します。 そんなやつはどうせiPhoneでも使ってやがるんだろうと思います。
というのも、Android端末をよく使う人にはわかってもらえると思うのですが、Androidの「戻る」ボタン/ジェスチャーで、モーダルを消せる気がするんですよね。 そのとき、単にページ内の状態管理だけで実装しているモーダルだと、当然前の画面に戻ってしまいます。
なので、「戻る」と↓こうなっちゃっていました。
余談ですが、そう思うと、「戻る」に対してはiPhoneの「画面の左端からスワイプする」って動作のほうが直感的なのかもしれないですね。 あとPCだと、Escapeキーで消そうとして消えないこともあります。Escapeは何も起きないだけなのでましですが、消えてほしい。
対応方法
このブログでは、この記事の公開と同時に「戻る」で消えるようにしました (該当の変更はこのPR)。
まず、主に想定しているのはAndroidの「戻る」ボタン/ジェスチャーですが、これはブラウザ側での「戻る」実行になってしまうので、Webサイトで直接イベントとして拾うことはできないようです。 なので、単にブラウザの「戻る」でモーダルが消えるようにする必要があります。 そのために、モーダルを開くときにブラウザの履歴に「クエリパラメータ付きの同ページ」を追加するようにしました (↓な感じ)。
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]);
その上で、「戻る」を検知してモーダルを閉じるようにしています (↓な感じ)。
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
の全体像は↓のようになりました。
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 &&
は不要ですね…。hasShowModalParam
がfalse
のときは必ずisModalTarget
もfalse
になるので…。
まとめ
- Androidの「戻る」ボタン/ジェスチャーはモーダルを閉じれる気がしてしまう
- そのときに何もしていないとそのまま前のページに戻ってしまう
- ↑を防ぐためには、モーダルを開くときに履歴に何かを追加する必要がある
- その上で「戻る」を検知してモーダルを閉じるといい感じ
- そのとき「進む」で再表示されないほうが直感的な気がする
PC/iOSでは「戻る」でモーダルを消そうとすることはないかもしれませんが、ぜひAndroidユーザーのために、モーダルは「戻る」で消せるようにしてください。