Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

scrollIntoView()では不可能な位置へと要素をスクロールさせたいときはscrollBy()を使うテクがある

ブラウザ上の要素を視認可能な位置へとスクロールする設計について調べて実装する機会があったのでメモ。

tl;dr

const offsetPercentage = 25;
const [parentRect, targetRect] = [
  parent.getBoundingClientRect(),
  target.getBoundingClientRect(),
];
const offset = parentRect.height * offsetPercentage * 0.01;
const diff = targetRect.top - parentRect.top - offset;
parent.scrollBy({ left: 0, top: diff, behavior: 'smooth' });

背景と課題

大前提として、element.scrollIntoViewを利用すると要素をトップ/中央/ボトムにスクロールしてくることができる。

developer.mozilla.org

このため、一般的に要素を視認可能な位置へとスクロールさせるための手段として、element.scrollIntoViewが利用されている。

しかしながら、このメソッドで対応できない要件がある。例えば、中央よりやや上とか下の位置にスクロールさせたい場合、element.scrollIntoViewでは対応できない。element.scrollIntoViewはトップ/中央/ボトムにしかスクロールさせられないからである。

このため、トップ/中央/ボトム以外の位置に要素をスクロールさせたい場合は、別の仕組みを用いてスクロールしなければならない。

より拡張性の高いスクロール実装

今回採用した技法は、wrapper.scrollByにスクロール差分を渡すというものである。

developer.mozilla.org

scrollByはターゲットとなる要素ではなく、スクロール差分を受け取ってそのぶんだけ要素をスクロールさせるという振る舞いになっている。このため視認可能な位置にスクロールさせる用途で使うには、ターゲット要素ではなくそのラッパーをレシーバとして呼び出す必要がある。

スクロール差分は、ターゲット要素の縦オフセット - ラッパー要素の縦オフセット - 追加のスクロール差分で表現できる。

コード例

以下のようなコードによって、任意(この例では#parentの25%)の位置に要素をスクロールさせられる:

<div id="parent"> <!-- 縦のサイズが決まっており、はみだすとスクロールしなければならない -->
  <ul>
    <!-- たくさんli要素があり、スクロールする必要がある -->
    <li id="target">
    <!-- たくさんli要素があり、スクロールする必要がある -->
  </ul>
</div>
const offsetPercentage = 25;
const [parentRect, targetRect] = [
  parent.getBoundingClientRect(),
  target.getBoundingClientRect(),
];
const offset = parentRect.height * offsetPercentage * 0.01;
const diff = targetRect.top - parentRect.top - offset;
parent.scrollBy({ left: 0, top: diff, behavior: 'smooth' });

原理解説

この技法はhttps://developer.mozilla.org/ja/docs/Web/API/Element/getBoundingClientRect getBoundingClientRect()を利用しているため、その説明から行う。

getBoundingClientRect()とビューポート

このメソッドは、レシーバ要素のビューポートに対するオフセットとサイズを返す。以下に、Mozilla 公式ページより抜粋した図を示す:

ビューポートとは、要するに今画面に視えている部分の矩形のことである。デバイスによってビューポートは変動するし、ウィンドウサイズの変更によってもビューポートは変動する。

また、ビューポートはページ先頭の位置から独立した概念である。ページ先頭からビューポートへのオフセットは、別途window.scrollYで取得できる。ここまでがビューポートの基礎である。

最も単純なスクロール処理について考える

まず、もっとも基礎的なスクロールとして「ターゲット要素をビューポートの先頭までスクロールさせること」を考える。この結果、ターゲット要素は視えている画面の一番上に占位するはずである。また、親要素のサイズは限られているため、親要素を上にはみ出す(視えない)形になるはずである。

このためには、要素のビューポートに対するオフセットぶんだけスクロールさせればよい。すなわち:

  • parent.scrollBy(0, target.getBoundingClientRect().top)する
  • すると要素はビューポートの先頭位置までスクロールされる

「ターゲット要素のtopがビューポートの先頭からどれだけ離れているか」のぶんだけ「親要素をスクロール」させるので、当然ターゲット要素はビューポートの先頭まで移動する。

これが最も単純なスクロールであり、最終的な成果はこの変形として考えることになる。

ターゲット要素を親要素の先頭にスクロールさせる

さて、前項のやり方では親要素の表示可能な範囲を突き破ってもっと上に行ってしまう。このため、適切にスクロール量を抑制する必要がある。

適切なスクロール量について考えるために、親要素に対してどのくらい上に行きすぎているかについて考えると、ちょうど親要素のビューポートに対するオフセットぶん行きすぎていることがわかる。

したがって、親要素のビューポートに対するオフセットぶんをスクロール量から減算すれば、ターゲット要素は親要素のちょうど先頭で停止するはずである。すなわち:

  • parent.scrollBy(0, target.getBoundingClientRect().top - parent.getBoundingClientRect().top)
  • すると、ターゲット要素はちょうど親要素の先頭位置で停止する

これが、任意の位置にスクロールさせたいときの基本的な考え方である。

ターゲット要素を親要素の任意の位置にスクロールさせる

後は簡単だ。例えばターゲット要素を親要素の上位25%の位置に占位させたい場合は、さらにスクロール量を抑制すればよい。その量は、親要素の高さ(横スクロールについて考える場合は幅)に25%を掛ければそのまま求まる。

したがって冒頭の形(再掲)に落ち着く:

const offsetPercentage = 25;
const [parentRect, targetRect] = [
  parent.getBoundingClientRect(),
  target.getBoundingClientRect(),
];
const offset = parentRect.height * offsetPercentage * 0.01;
const diff = targetRect.top - parentRect.top - offset;
parent.scrollBy({ left: 0, top: diff, behavior: 'smooth' });

この例ではbehaviorsmoothを指定することで、なめらかなスクロールを行っている。

これまでの考え方を図にして整理したものが以下である:

必要なスクロール量は、ビューポートからのオフセットから、親要素のビューポートからのオフセットと、追加で下に降ろす分のオフセットを減ずればよいということが視覚的に分かるはずだ。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?