TECH BOX

Technology blog from Web Engineer

この記事は最終更新日から5年以上経過しているため正確ではないかもしれません

吸着スクロールの実装

前回は独自スクロールを実装するリファレンスを公開しましたが、今回はその派生となります。

スクロール完了時にある一定の位置に要素の上側が必ず来るようにします。

これだけだと分かりませんね。
擬似的なドラムロール機能みたいなもので、ある特定の領域内に要素が収まるようなスクロールイベントを作成します。

以前、リリースしたウェブアプリLsC !でも使っています。

実際にサンプルを用意しました。
今回はこの実装方法を紹介します。
※今回はリサイズイベントは省きます

See the Pen 吸着スクロール by Nobuyuki Kondo (@artprojectteam) on CodePen.0

前準備: HTMLとCSS

どこを吸着する位置にするかを決める必要があるため、今回は赤い枠をつけました。
この時、重要なのはCSSで前回とは違い、一番上の要素と最後の要素が赤枠に収まるように上下に余白を設定します。
これがズレるとJavaScriptでもズレることがあります。

※JavaScriptで動的にul要素にpaddingを設定するとリサイズ等で直接書いたCSSが消えてしまうので注意

赤枠は中心ではなく、あえて上よりの位置に設定しました。
この赤枠はabsoluteで配置しています。
また、赤枠には必ずpointer-events: noneを設定しましょう。
これを設定しないと赤枠内だとイベントが発生しなくなってしまいます。

Scrollクラスの作成

さて、前回同様、スクロールイベントを作成します。
今回は赤枠の情報を取得する必要があるため、そのIDを追加で渡すようにします。

セットアップ

セットアップを行います。
コンストラクタで初期化してないのはリサイズ時に再度情報を取得するためです。
※今回はリサイズ処理を入れてませんが汎用性を考慮

_setup() {
  // CSS要素を数値で返却
  const elemStyle = (elem, style) => {
    const res = document.defaultView.getComputedStyle(elem)[style]
    return parseInt(res, 10)
  }

  this._pos = 0

  // 各要素の位置を格納
  this._box.list = []

  // 見えている領域が必ず最上部とは限らないため、現在見えている位置のスクロール量を取得
  const offset = window.pageYOffset

  // 親要素のコンテンツ開始位置を取得
  const borderTop = elemStyle(this._container.elem, 'borderTopWidth')
  const parentOffset = offset + this._container.elem.getBoundingClientRect().top + borderTop

  // 付与しているCSSを削除して位置をリセット
  const box = this._box.elem
  box.removeAttribute('style')

  // 子要素のリスト一つ一つの座標を格納
  for (let i = 0, iLen = box.children.length; i < iLen; i++) {
    const elem = box.children[i]

    // 親要素から見たY座標値
    const y = offset + elem.getBoundingClientRect().top - parentOffset
    this._box.list.push(y)
  }

  this._padding = elemStyle(box, 'paddingTop')

  // 子要素の高さ
  this._box.h = box.clientHeight

  // 赤枠の中心位置を取得(スクロール領域の親要素)
  const bdt = elemStyle(this._active.elem, 'borderTopWidth')
  const activeCenter = Math.floor(this._active.elem.clientHeight / 2)
  this._active.top = offset + this._active.elem.getBoundingClientRect().top + bdt - parentOffset
  this._active.center = this._active.top + activeCenter
  this._active.half = activeCenter
  this._active.bottom = this._active.center + this._active.half
}

位置を取得するのは意外と大変です。

ブラウザのオフセット位置を取得

// 見えている領域が必ず最上部とは限らないため、現在見えている位置のスクロール量を取得
const offset = window.pageYOffset

なぜ、この一文が存在しているかというと、コメントの通り今見ているコンテンツの位置が必ず最上部とは限らず、スクロールされた状態かもしれません。
その場合に取得できる各要素の座標はその分ずれて取得されます。
それを補完するためにこのoffsetが必要になります。

このoffsetelement.getBoundingClientRect().topを足すことでドキュメント自体の開始(<body>直下)から見た絶対座標を取得できます。

各要素の座標値を取得

要素の座標はgetBoundingClientRect().topで取得できますが、border位置までとなり、コンテンツの有効位置とは違うため別途borderのサイズも取得します。

このgetBoundingClientRectは見えている範囲の左上から見た座標を取得します。
これだけだと、スクロール位置によって毎度変わるため上述のpageYOffsetが必要になります。

長々と書いていますが、下記図をそれぞれに合わせて計算しています。

fit-scroll-1

子要素の場合は更にそこから親要素で算出した上記値を引くと、親要素から見た座標が取得できます。

赤枠の情報を取得

赤枠の情報で必要なのは下記になります。

  • 赤枠の中心Y座標(this._active.half)
  • 親要素から見た赤枠の中心Y座標(this._active.center)
  • ファジィ理論で使うための三角形の終端位置(this._active.top / this._active.bottom)

図にすると下記のようになります。

fit-scroll-2

サンプルの最下部にthis._active.centerを表示させるボタンがありますので確認してみてください。

イベントの登録

イベントの大枠は前回と同様です。
違う箇所は下記になります。

_swipe() {
  // (前略)

  const evEnd = () => {
    // (前略)

    const index = this._searchIndex(pos)
    pos = -(this._box.list[index] - this._padding)

    this._animation(pos, 0.3)

    // 初期化
    this._pos = pos
    startY = null
  }
}

_wheel() {
  const lastPos = this._container.h - this._box.h
  let timer = null

  const ev = (e) => {
    e.preventDefault()
    clearTimeout(timer)

    // (中略)

    this._animation(pos, 0)
    this._pos = pos

    timer = setTimeout(() => {
      const index = this._searchIndex(pos)

      pos = -(this._box.list[index] - this._padding)
      this._animation(pos, 0.2)
      this._pos = pos
    }, 100)
  }
}

_searchIndexというメソッドを呼び出して、posの位置を最終決定しています。
このメソッドは下記のようになっています。

setTimeoutはマウススクロール完了を取得しています。
(イベントの実行間隔が100msを超えたらスクロール終了)

// スクロール後の位置から一番目的地に近い要素のインデックスを取得
_searchIndex(pos) {
  let old = -1

  for (let i = 0, iLen = this._box.list.length; i < iLen; i++) {
    const p = this._box.list[i] + this._active.half + pos
    const t = Triangle(
      p,
      this._active.top,
      this._active.center,
      this._active.bottom
    )

    if (t === 1) return i

    // 一つ前のほうが1に近い
    if (old > t) return i - 1

    old = t
  }

  // 一番最後が1に近かった
  return this._box.list.length - 1
}

ファジィ理論の三角形型を利用して1に一番近い要素のインデックスを速攻で返します。
※Triangle関数はファジィ理論の三角形型を参照

まとめ

吸着スクロールに関するロジックはこれで終了です。
リサイズイベントなどは前回の独自スクロールの実装を参考にしてください。

実際にこういう表示だけで使うことは少なく、疑似ドラムロールのように使うことが想定できます。
返却されたindexをクラスのプライベート変数か何かに格納しておけば、好きなように使えますよ。

最後に、今回使ったサンプルを下記にも貼っておきます。

See the Pen 吸着スクロール by Nobuyuki Kondo (@artprojectteam) on CodePen.0