ピンチズームした時の座標のズレと2つの viewport

  • 2
    いいね
  • 0
    コメント

ウェブブラウザでピンチインすると拡大ができるが、この状態で window.scrollX/Y, getBoundingClientRect/getClientRect にアクセスをすると意図しない座標が取れたので調査したのでメモ。

tl;dr 相対座標を扱う時は visual viewportlayout viewport の2つの viewport のどちらをオフセットにしたものか意識することが必要。

具体的に簡単なアプリケーションコードを示して問題をハッキリさせる。基本的にココにあるコードは全部 google chrome を前提にしている。

問題になるコード

ページ上の適当な要素をダブルクリックするとその要素を覆い被せるように移動してくるお邪魔エレメントです。コンソールにコピペすると確認できる:

(() => {
  const d = document.body.appendChild(document.createElement("div"));
  Object.assign(d.style, {
    position: "absolute",
    backgroundColor: "black",
    opacity: "0.3",
    transition: "500ms",
    top: "0",
    left: "0",
  });
  window.addEventListener("dblclick", ({ target }) => {
    const r = target.getBoundingClientRect();
    Object.assign(d.style, {
      top:  `${r.top + scrollY}px`,
      left: `${r.left + scrollX}px`,
      width: `${r.width}px`,
      height: `${r.height}px`,
    });
  });
})();

このコードは、拡大をしていない状態では特に問題なく機能する。しかし、ピンチインをして少しでもページを拡大をすると、このお邪魔エレメントは必ず右下にズレて移動をするようになってしまう。大きさは適切なのに。

原因

scrollY/scrollXgetBoundingClientRect/getClientRect はそれぞれ違うものを基準にした座標を扱っている:

  • scrollY/scrollXvisual viewport の左上端の座標を示している。
  • getBoundingClientRect/getClientRectlayout viewport の左上端からの相対座標の要素の四角形を示している。

これは visual viewport はその名の通り拡大したときに見えている範囲で、layout viewport は拡大していないときに見える範囲ということ。

この2つの viewport は、拡大していない状態なら同じ四角形の範囲を示しているため、上記のお邪魔エレメントも正常にお邪魔する。しかし拡大をすると、visual viewport は layout viewport にすっぽり収まる小さい四角形になり、意図したとおりにお邪魔してくれなくなる。

本当は図を示して見たほうがいいけどポンチ絵書くの苦手なので他の便利ページを参照:

どうするの

実際に JavaScript で扱うときにこれらにどうアクセスするのかという話。ズバリそのものの名前のアクセッサは無いのでちょっとコードが必要。

いかのそれぞれの座標はページ左上端を起点とした絶対座標を指しています。

visual viewport

visual viewport の左上端の座標:

function getVisualViewportOffsets() {
  return {
    y: window.scrollY, 
    x: window.scrollX,
  };
}

visual viewport のサイズ:

function getVisualViewportSize() {
  return {
    width:  window.innerWidth,
    height: window.innerHeight,
  };
}

layout viewport

layout viewport の左上端の座標:

function getLayoutViewportOffsets() {
  const rootRect = document.documentElement.getBoundingClientRect();
  return {
    y: - rootRect.top,
    x: - rootRect.left,
  };
}

layout viewport のサイズ:

function getLayoutViewportSize() {
  return {
    height: document.documentElement.clientHeight,
    width: document.documentElement.clientWidth,
  };
}

問題になったコードを修正しよう

getBoundingClientRect は layout viewport からの相対距離なので、layout viewport の座標を足し算をしよう。scrollX/Y だと足しすぎて上で確認したように右下にズレてしまう。

つまりこうなる:

(() => {
  const d = document.body.appendChild(document.createElement("div"));
  Object.assign(d.style, {
    position: "absolute",
    backgroundColor: "black",
    opacity: "0.3",
    transition: "500ms",
    top: "0",
    left: "0",
  });
  window.addEventListener("dblclick", ({ target }) => {
    const layoutVpOffsets = getLayoutViewportOffsets();
    const r = target.getBoundingClientRect();
    Object.assign(d.style, {
      top:  `${r.top + layoutVpOffsets.y}px`, // ← scrollY 足すのやめた
      left: `${r.left + layoutVpOffsets.x}px`,// ← scrollX 足すのやめた
      width: `${r.width}px`,
      height: `${r.height}px`,
    });
  });
  function getLayoutViewportOffsets() {
    const rootRect = document.documentElement.getBoundingClientRect();
    return {
      y: - rootRect.top,
      x: - rootRect.left,
    };
  }
})();

これもコピペして実行できる。ピンチインして拡大してもズレなくお邪魔エレメントが覆い被るように移動してくる。

解決。

補足

それぞれの viewport の説明は非常に雑なので、キチンと扱うには言及不足。

スクロールバーの有無でもこの2つの viewport はズレるみたいだけど、今回は簡単のため考慮しないで書いてる。後述の polyfill を参考にすると良いかも。

visual viewport に関してはズバリそのもののアクセッサを用意しようって話はあるみたい:

この polyfill は Google Chrome 以外での viewport の扱いについてクロスブラウザを意識して書かれている。ので chrome 以外の対応が必要なら参考になる。

また、サンプルで用意したダブルクリックするとその要素に被る要素の top/left は、本当に正確にいろんなサイトで動かすには body 要素の positionstatic かそれ以外かを判断し、static 以外なら body の座標も考慮する必要がある。