レスポンシブ対応のモバイルビューを PC から確認できるプレビュー環境を作った話

背景

もともと 業務Web アプリを PC 前提で開発していたのですが、途中で「レスポンシブ対応してモバイルでも綺麗に表示したい」という要件が出てきました。

開発者自身は開発者ツールを使ってモバイルビューの確認ができます。ただ、確認の場を開発者ツール内に閉じておくのは運用としてなかなか辛いものがあります。そこで、もっと手軽に・誰でもモバイルの見た目や挙動を確認できる仕組みが欲しくなりました。

やったこと

Web アプリのデプロイと同時に、別の URL で PC 上からモバイルの動作確認ができるプレビュー環境を構築しました。

URL を共有するだけで、スマホやタブレットのビューをいつでも・誰でも確認できるようになっています。

実装の概要

構成としては非常にシンプルで、HTML ファイル 1 枚(ペラ)です。

  • HTML ファイル内で iframe を使い、実際の Web アプリ(PC 向け URL)を読み込む
  • iframe のサイズをモバイル端末相当(iPhone や iPad など)に設定する
  • これによって、擬似的に PC 上でモバイルサイズの動作確認ができる

イメージとしては以下のような形です。

見た目もモバイルっぽく作り込んであるので、開いたときに「モバイルを操作している感覚」になるようにしています。

ハマったところ

1. フリック操作が効かない

ブラウザの開発者モード(レスポンシブモード)とは違い、こちらは本当にただの HTML ファイルです。そのため、フリック操作などがうまく効きませんでした。

そこで、アプリ側にいくつか開発用の仕込みを入れる必要がありました。具体的には、URL パラメーターでフリック操作を有効化するフラグを立てられるようにし、そのフラグを立てるとフリック操作が効くようにする、という工夫です。


export function enableDragScroll(): void {
  let el: Element | null = null; // スクロール対
  let lastY = 0;
  let lastX = 0;
  let vy = 0; // 速度(慣性用)
  let vx = 0;
  let dragging = false;
  let moved = false;
  let raf = 0;

  // クリック位置から最も近いスクロール可能な祖先を探す
  function findScrollable(start: EventTarget | null): Element | null {
    let node = start instanceof Element ? start : null;
    while (node && node !== document.body) {
      const s = getComputedStyle(node);
      const canY =
        /(auto|scroll)/.test(s.overflowY) &&
        node.scrollHeight > node.clientHeight;
      const canX =
        /(auto|scroll)/.test(s.overflowX) &&
        node.scrollWidth > node.clientWidth;
      if (canY || canX) return node;
      node = node.parentElement;
    }
    return document.scrollingElement; // ページ全体
  }

  addEventListener(
    'mousedown',
    (e: MouseEvent) => {
      if (e.button !== 0) return;
      cancelAnimationFrame(raf);
      el = findScrollable(e.target);
      dragging = true;
      moved = false;
      lastY = e.clientY;
      lastX = e.clientX;
      vy = 0;
      vx = 0;
    },
    true
  );

  addEventListener(
    'mousemove',
    (e: MouseEvent) => {
      if (!dragging || !el) return;
      const dy = e.clientY - lastY;
      const dx = e.clientX - lastX;
      lastY = e.clientY;
      lastX = e.clientX;
      if (Math.abs(dy) + Math.abs(dx) > 2) moved = true;
      el.scrollTop -= dy; // 指と同じ方向に画面が付いてくる
      el.scrollLeft -= dx;
      vy = dy; // 直近速度を保持
      vx = dx;
    },
    true
  );

  addEventListener(
    'mouseup',
    () => {
      if (!dragging) return;
      dragging = false;
      // 慣性スクロール
      const decay = 0.95;
      const momentum = (): void => {
        if (!el) return;
        if (Math.abs(vy) < 0.5 && Math.abs(vx) < 0.5) return;
        el.scrollTop -= vy;
        el.scrollLeft -= vx;
        vy *= decay;
        vx *= decay;
        raf = requestAnimationFrame(momentum);
      };
      momentum();
    },
    true
  );

  // ドラッグ後の誤クリック(リンク遷移など)を抑止
  addEventListener(
    'click',
    (e: MouseEvent) => {
      if (moved) {
        e.stopPropagation();
        e.preventDefault();
        moved = false;
      }
    },
    true
  );

  // テキスト選択がドラッグを邪魔しないように
  const style = document.createElement('style');
  style.textContent =
    '* { user-select: none !important; } input, textarea { user-select: text !important; }';
  document.head.appendChild(style);
}

2. iOS のスクロールとモーダルの裏抜け

もう一点、注意点がありました。

モーダルを表示したときに、背景(モーダルの裏)がスクロールで見えてしまうという問題です。プレビュー環境ではスクロールの挙動が実機のモバイルと完全には一致しないために起きるものでした。

ここについては、プレビュー環境でのスクロール操作は「モバイルと同じではない」ものとして割り切り、この挙動を許容することにしました。

まとめ

URL を共有するだけで、スマホ・タブレットのビューを誰でもいつでも確認できる環境を用意できました。

フリック操作やスクロールまわりでいくつか工夫や妥協が必要でしたが、Storybook 内での確認に閉じず、実際のデプロイ環境に近い形でモバイルビューを共有できるようになったのは大きなメリットでした。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mobile Preview</title>
<style>
  :root {
    --bezel: #111418;
    --bg: radial-gradient(1200px 800px at 50% -10%, #2a2f3a 0%, #14171d 60%, #0d0f13 100%);
    --accent: #4f9cff;
    --text: #e7ecf3;
    --muted: #8a93a3;
  }
  * { box-sizing: border-box; }
  html, body { height: 100%; }
  body {
    margin: 0;
    background: var(--bg);
    color: var(--text);
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 18px;
    padding: 20px;
    overflow: hidden;
  }

  .toolbar {
    display: flex;
    gap: 6px;
    background: rgba(255,255,255,.06);
    border: 1px solid rgba(255,255,255,.08);
    padding: 5px;
    border-radius: 12px;
    backdrop-filter: blur(8px);
  }
  .toolbar button {
    appearance: none;
    border: 0;
    background: transparent;
    color: var(--muted);
    font: inherit;
    font-size: 13px;
    padding: 7px 13px;
    border-radius: 8px;
    cursor: pointer;
    transition: .15s;
  }
  .toolbar button:hover { color: var(--text); }
  .toolbar button.active {
    background: var(--accent);
    color: #fff;
  }

  .stage {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 0;
    width: 100%;
  }

  .device {
    position: relative;
    background: var(--bezel);
    border-radius: 46px;
    padding: 13px;
    box-shadow:
      0 0 0 2px rgba(255,255,255,.05),
      0 30px 60px -15px rgba(0,0,0,.7),
      inset 0 0 3px rgba(255,255,255,.15);
    transition: width .25s ease, height .25s ease; 
  }
  .screen {
    position: relative;
    width: 100%;
    height: 100%;
    background: #fff;
    border-radius: 34px;
    overflow: hidden;
  }
  .notch {
    position: absolute;
    top: 9px;
    left: 50%;
    transform: translateX(-50%);
    width: 34%;
    height: 26px;
    background: #000;
    border-radius: 20px;
    z-index: 2;
    pointer-events: none;
  }
  iframe {
    display: block;
    width: 100%;
    height: 100%;
    border: 0;
    background: #fff;
  }

  .hint {
    font-size: 12px;
    color: var(--muted);
    max-width: 90vw;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .hint code { color: var(--accent); }
</style>
</head>
<body>
  <div class="toolbar" id="toolbar">
    <button data-w="390" data-h="844">iPhone</button>
    <button data-w="360" data-h="800">Android</button>
    <button data-w="320" data-h="568">Small</button>
    <button data-w="768" data-h="1024">Tablet</button>
  </div>

  <div class="stage">
    <div class="device" id="device">
      <div class="screen">
        <div class="notch"></div>
        <iframe id="frame" title="app preview"></iframe>
      </div>
    </div>
  </div>

  <div class="hint" id="hint"></div>

  <script>
    const DEFAULT_URL = "https://sksp-tech.net/%e6%8a%95%e7%a8%bf";

    const params = new URLSearchParams(location.search);
    const appUrl = params.get("url") || DEFAULT_URL;
    const u = new URL(appUrl, location.href);
    u.searchParams.set("touch-emu", "1");   // ★ 常に付与
    document.getElementById("frame").src = u.toString();
    document.getElementById("hint").innerHTML = "表示中: <code>" + appUrl + "</code>";

    const device  = document.getElementById("device");
    const buttons = [...document.querySelectorAll(".toolbar button")];

    let current = { w: 390, h: 844 };

    function fit() {
      const stage = device.parentElement.getBoundingClientRect();
      const dw = current.w + 26;
      const dh = current.h + 26;
      const scale = Math.min(1, (stage.width - 8) / dw, (stage.height - 8) / dh);
      device.style.zoom = scale; 
    }   

    function setSize(w, h, btn) {
      current = { w, h };
      device.style.width  = w + "px";
      device.style.height = h + "px";
      buttons.forEach(b => b.classList.toggle("active", b === btn));
      fit();
    }

    buttons.forEach(b =>
      b.addEventListener("click", () => setSize(+b.dataset.w, +b.dataset.h, b))
    );
    window.addEventListener("resize", fit);

    setSize(current.w, current.h, buttons[0]); // 初期表示
  </script>
</body>
</html>
タイトルとURLをコピーしました