TECH BOX

Technology blog from Web Engineer

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

独自スクロールの実装

殆どが作ってもその地味さ故に評価されにくい。
でも、実は作るのけっこう大変だったりします。
それが独自スクロールの実装。

大体において、要素内の内部スクロールなんかもCSSでできてしまうのですが、仕組みを知っておくというのは今後のためになる……はず。

昔、jQueryを使った内部スクロールライブラリを公開しましたが、今回はjQueryは使わない方法を紹介します。

ちなみに、当ブログのモバイルでのサイドメニューは独自スクロールです。

スクロールエリアの仕組み

スクロールするエリアかどうかは特定の要素内でもウィンドウでも同じ仕組みです。
要は、外側(親)の枠より内部のコンテンツ(子)のほうが大きいかどうかです。

scroll-1.png

スクロールするエリア(子)は常に一定方向(上や右)に向けて進むため、子要素のポジションが正の数値になる事はありません。
※モバイルでは引っ張るとポジションが正の数値になりますが、離すと0に戻ります

何をするにもスタンダードなスクロールが作れるようになる必要がありますので、今回の記事ではスタンダードなスクロールの実装方法を紹介します。

余談: CSSでのスクロール

本題に入る前に、CSSの場合は簡単に実装できます。

See the Pen scroll-1 by Nobuyuki Kondo (@artprojectteam) on CodePen.0

親要素に横と縦のサイズを指定し、overflow: auto;で子要素が親要素より大きければスクロールすることができます。

また、-webkit-overflow-scrolling: touch;を指定すればiOSでもなめらかなスクロールをすることができます。

ただし、サイズが不明の場合はこの方法が使えません。
もちろん、JavaScriptでサイズを指定してあげれば解決します。

もう一つ問題があります。
デスクトップではスクロールバーが存在するようになるということです。
デザインによってはスクロールバーはなくしたい、あるいは目立たなくしたいということもあるでしょう。
そういった場合は、そういうライブラリを使うか、独自にスクロール処理を作る必要があります。

JavaScriptを使って独自に実装する

さて、本題です。
スクロールを実装するに当たって重要になるのはスクロール可能かどうかです。

  • そもそも領域をスクロールさせられるか
  • 上(右)へまだずらせるか
  • 下(左)へまだずらせるか

この判定さえあれば、あとは計算するのみになります。

まずは完成形のサンプルから紹介します。

See the Pen scroll-2 by Nobuyuki Kondo (@artprojectteam) on CodePen.0

これをベースに解説していきます。

実装前の前準備

いきなり実装したいところですが、ロジックを作成する前に様々なデバイスで動作させるための前準備が必要になります。

準備1: ホイールイベント名はブラウザによって違う

いきなり面倒な話です。
そう、ブラウザバージョンによってホイールイベント名が違うのです。
なので、イベント名を統一することから始めます。

const wheel = 'onwheel' in document ? 'wheel' : 'onmousewheel' in document ? 'mousewheel' : 'DOMMouseScroll'

準備2: タッチ系はクロスデバイス対応する

昨今はタッチだのペンだのいろいろな方法があります。
なので、対応しているイベント名を取得します。

// タッチイベントが有効かの判定
const isTouch = typeof document.ontouchstart !== 'undefined'

// ポインターイベントを持っているかの判定
const isPointer = window.navigator.pointerEnabled

const pointer = {
  start: isPointer ? 'pointerdown' : isTouch ? 'touchstart' : 'mousedown',
  move: isPointer ? 'pointermove' : isTouch ? 'touchmove' : 'mousemove',
  end: isPointer ? 'pointerup' : isTouch ? 'touchend' : 'mouseup'
}

イベントはポインターイベント > タッチイベント > マウスイベントの順に指定しています。
ポインターイベントはsurfaceなどのタッチ系でペンを使うデバイスで認識されるイベント名です。

今回はIE10以下は対応していません。
対応する場合はwindow.navigator.msPointerEnabledとベンダプレフィックスをつければ取得できます。

なぜこのようなことをするかというと、イベントが実装されているかどうかで判断することで、特定のデバイスの時はこうするというような書き方(例えばユーザーエージェントで判別するとか)をしなくて済むためです。

準備3: CSSの準備

さて、JSの実装を…と言いたいところですが、その前に入れ物の前準備も必要です。

CSSでのスクロールでは親要素にoverflow: auto;にしてスクロール判定はCSSに任せてましたが、今度は自前で作るのでoverflow: hidden;にします。

また、タッチ/ペンデバイスで拡大縮小できないようにtouch-action: none;を設定しておくと良いです。

Scrollクラスの作成と初期化

さて、ようやく本題のスクロールを操作するロジックの話になります。
必ず一つだけしかないというのであればクラス化する必要はありませんが、せっかくなので汎用的に使えるようクラス化します。

※ここから先はBabelで記述しています

class Scroll {
  constructor (containerId, boxId) {
    // 親要素
    const container = document.getElementById(containerId)
    this._container = {
      elem: container,
      h: container.offsetHeight
    }

    // 子要素
    const box = document.getElementById(boxId)
    this._box = {
      elem: box,
      h: 0  // 後で設定
    }

    this._windowSize = 0

    this._pos = 0
    this._setup()
  }
}

まずはなにがなんでも初期化です。
ここでクラスに渡す引数は親要素のIDとスクロールする要素(子要素)のIDになります。

親要素の高さをここで指定していますが、リサイズなどで高さが変わる場合は子要素と同じくあとで設定するようにしてもいいです。

ここで初期化しているthis._windowSizeはリサイズ判定で使用します。

セットアップ

初期化が済んだらセットアップを行います。
なぜ初期化で行わないかというと、リサイズする際にこの情報のリセットを再度行うために分離しています。

_setup () {
  // 子要素の高さを取得
  this._box.h = this._box.elem.offsetHeight

  this._box.elem.removeAttribute('style')
  this._pos = 0
}

子要素はリサイズによって高さが変わることを想定しています。
親要素もサイズが変わる場合はここで高さを取得しても良いです。

アニメーションの設定

セットアップしたので「よし!イベントの登録だ!」と行きたいところですが、その前にアニメーションを設定します。

_animation (pos, duration) {
  const ops = `${duration}s linear`
  const transform = `translate3d(0, ${pos}px, 0)`
  const elem = this._box.elem

  elem.style['-webkit-transform'] = transform
  elem.style['transform'] = transform
  elem.style['-webkit-transition'] = `-webkit-transform ${ops}`
  elem.style['transition'] = `transform ${ops}`
}

今はもうCSS3が使えないブラウザなんてないのでCSS3で実装します。
ただ、ブラウザによってはまだ-webkit-のベンダプレフィックスが必要だったりします。
早くなくなればいいのに…。

今回はtranslate3dにしてWebGLでの動作にしています。
WebGLにすると動作がなめらかになります。

引数にdurationを渡すのはムーブイベントの時にイージングさせたりさせなかったりを操作するためです(0が渡るとイージングしません)。

また、イージングは好きなようにすればいいですが直線的なlinearを使うのをおすすめします。

タッチ系イベントの登録

ようやく前準備が終わったのでここから各イベントを作成します。
まずはタッチ系イベントを登録します。

_swipe () {
  // 子要素の最後が見える座標値
  const lastPos = this._container.h - this._box.h

  // 座標の一時格納
  let startY = null
  let startTime = null
  let movePos = null
  let endTime = null

  const elem = this._container.elem

  /* スタートイベント */
  const evStart = (e) => {
    // touchstartのときはtouchイベントを拾う
    startY = e.touches ? e.touches[0].pageY : e.pageY
    startTime = e.timeStamp

    // 初期化
    movePos = null
    endTime = null
  }

  this._ev.start = evStart.bind(this)
  elem.addEventListener($pointer.start, this._ev.start, false)

  /* ムーブイベント */
  const evMove = (e) => {
    // startを通ってない場合は処理しない
    if (startY == null) return false

    // ウィンドウ全体をスクロールさせない
    e.preventDefault()

    const pageY = e.touches ? e.touches[0].pageY : e.pageY

    // 移動させる px数を計算
    movePos = Math.floor(pageY - startY - Math.abs(this._pos))

    this._animation(movePos, 0)

    endTime = e.timeStamp
  }

  this._ev.move = evMove.bind(this)
  elem.addEventListener($pointer.move, this._ev.move, { passive: false })


  /* エンドイベント */
  const evEnd = () => {
    // moveイベントを通ってなければ単なるクリックと判定
    if (movePos == null) return false

    // 最終px位置の格納
    let pos = 0

    // 要素の高さと最終的な移動量の差分
    const diffH = this._box.h - Math.abs(movePos)

    if (movePos > 0) {
      // 元々移動なんてしてなかった
      pos = 0
    } else if (diffH < this._container.h) {
      // 下に行き過ぎた!
      pos = lastPos
    } else {
      const vertical = movePos - this._pos
      const diffTime = endTime - startTime

      // すんなり止まると違和感あるので慣性を効かせるために付与する移動量
      const reverb = Math.floor(Math.abs(vertical) / diffTime * 100)

      if (vertical > 0) {
        // 下へ移動
        pos = movePos + reverb
        if (pos > 0) pos = 0
      } else {
        // 上へ移動
        pos = movePos - reverb
        if (pos < lastPos) pos = lastPos
      }
    }

    this._animation(pos, 0.4)

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

  this._ev.end = evEnd.bind(this)
  elem.addEventListener($pointer.end, this._ev.end, false)
}

開始 – ムーブ – 終了を定義するため長いです。
なので、少しずつ説明します。

イベント登録前

イベントを登録する前に初期化がここでも必要です。
ここでの初期化はこのメソッド内でしか使いません。

// 子要素の最後が見える座標値
const lastPos = this._container.h - this._box.h

// 座標の一時格納
let startY = null
let startTime = null
let movePos = null
let endTime = null

const elem = this._container.elem

ここで重要なのが一番はじめのlastPosになります。
これは子要素をどれだけマイナスすれば最後の要素が見えるかを指定しています。

スタートイベント

モバイルで言う要素にタッチした瞬間のイベントを登録します。

/* スタートイベント */
const evStart = (e) => {
  // touchstartのときはtouchイベントを拾う
  startY = e.touches ? e.touches[0].pageY : e.pageY
  startTime = e.timeStamp

  // 初期化
  movePos = null
  endTime = null
}

this._ev.start = evStart.bind(this)
elem.addEventListener($pointer.start, this._ev.start, false)

タッチイベントでなければtouchesという情報が存在しません。
それを回避するためにtouchesがあればその情報、なければ直接pageYの情報を取得します。

座標を取るのも大事ですが、ここでは必ずmovePosnullにしてあげることが重要です。

最後の2行は直接addEventListener(evStart.bind(this))としてしまえばいいと思いがちですが、bindはその時のthisを渡すので、removeEventListenerでbind(this)を渡してもその時のthisとは違うため破棄できないのです。
こういうときはjQueryを使ったほうが楽ですね、ほんと。

ムーブイベント
/* ムーブイベント */
const evMove = (e) => {
  // startを通ってない場合は処理しない
  if (startY == null) return false

  // ウィンドウ全体をスクロールさせない
  e.preventDefault()

  const pageY = e.touches ? e.touches[0].pageY : e.pageY

  // 移動させる px数を計算
  movePos = Math.floor(pageY - startY - Math.abs(this._pos))

  this._animation(movePos, 0)

  endTime = e.timeStamp
}

this._ev.move = evMove.bind(this)
elem.addEventListener($pointer.move, this._ev.move, { passive: false })

ムーブ中は子要素を移動させます。
かつ、ムーブ中はウィンドウをスクロールさせないようにイベントを親に伝播させないようにします。
それが{ passive: false }になります。

第3引数が{ passive: true }だとイベント中にpreventDefault()を使わないという意味になります。
スクロール処理は連続で行われるため、まずはpreventDefaultが存在するかを判定してからその他の処理を行うことがあります。
そのため、結果としてパフォーマンスが落ちることがあります。

preventDefaultを利用しないとわかっている場合にはこの処理は無駄になるためあらかじめpreventDefaultを使わないと伝えてレンダリングをブロックさせないようにするオプションの一つです。
※ChromeとFirefoxの場合はデフォルトがtrueのようです
※詳しくはMDN – EventListenerを参照

移動中の計算はそんなに難しくなくて、ムーブイベントで取得した座標からスタートイベントの座標を引くことでどちらにどれだけ動いたかがわかります。
そこに前回終了したときの正数化した座標値を引いてあげれば移動させる量が出てきます。

例えば、スタート前の子要素のY座標が-20px、スタート時にタッチしたY座標の位置が50px、ムーブで取れたY座標が30pxだった場合、30px - 50px = -20pxで上へ20px引っ張ったことがわかります。
さらにそこからスタート前の子要素を引くと-20px - 20px = -40pxとなり、子要素の位置を-40pxにセットします。

ムーブイベントはいきなり50pxも100pxも移動することはないのでイベントが発行されている間、ずっと小刻みに計算するのであたかも要素を移動させているように見えます。

また、ムーブ中は子要素が0以上になった時などの判定は行わないです。
つまりムーブイベント中はどこまでも伸びます。
※リロードされないだけでChrome Mobileのようなプルリフレッシュと同じ動作です

エンドイベント

終了時は最終的な位置を確定させるためにいくつかの計算が必要になります。

/* エンドイベント */
const evEnd = () => {
  // moveイベントを通ってなければ単なるクリックと判定
  if (movePos == null) return false

  // 最終px位置の格納
  let pos = 0

  // 要素の高さと最終的な移動量の差分
  const diffH = this._box.h - Math.abs(movePos)

  if (movePos > 0) {
    // 下に引っ張り過ぎた!
    pos = 0
  } else if (diffH < this._container.h) {
    // 上に行き過ぎた!
    pos = lastPos
  } else {
    const vertical = movePos - this._pos
    const diffTime = endTime - startTime

    // すんなり止まると違和感あるので慣性を効かせるために付与する移動量
    const reverb = Math.floor(Math.abs(vertical) / diffTime * 100)

    if (vertical > 0) {
      // 下へ移動
      pos = movePos + reverb
      if (pos > 0) pos = 0
    } else {
      // 上へ移動
      pos = movePos - reverb
      if (pos < lastPos) pos = lastPos
    }
  }

  this._animation(pos, 0.3)

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

this._ev.end = evEnd.bind(this)
elem.addEventListener($pointer.end, this._ev.end, false)

必要な計算はムーブで動かした後の位置が正数だと要素が下りすぎているので0にし、子要素の最後が親要素の下位置にいない場合は上に行き過ぎているので調整。

これが基本となります。

しかし、このままではタッチを離した瞬間にピタッと止まるのでよくない。
ということで、少し慣性を入れます。
慣性の方法はいろいろありますが、今回は慣性の法則Tipsではないので最終移動した量と開始と終了の時間差を使うことにしました。
この辺は各自うまい具合にやればよいです。

さて、この慣性を最終的な座標値に付与します。
ここでも行き過ぎていた場合は元に戻すようにします。

これでタッチを離すと指定したduration秒かけて最後のムーブ時に移動させた値から最終値へイージングしながら移動します。

これでタッチ系イベントは完成です。

ホイールイベント

ホイールイベントはdelta値の取得をしたら、最終位置の調整をするだけになります。

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

  const ev = (e) => {
    // ウィンドウ全体のスクロールはさせない
    e.preventDefault()

    // ブラウザによってホイール移動した量の情報が格納されている場所が違う
    const delta = e.deltaY ? -(e.deltaY) : e.wheelDelta || -(e.detail)

    // そのままホイール移動量を付与すると一気に進んでしまうので、付与するホイール量を抑える
    let pos = Math.floor(this._pos + (delta / 3))

    if (pos > 0) {
      pos = 0
    } else if (pos < lastPos) {
      pos = lastPos
    }

    this._animation(pos, 0.1)
    this._pos = pos
  }

  this._ev.wheel = ev.bind(this)
  this._container.elem.addEventListener($wheel, this._ev.wheel, { passive: false })
}

一番気をつけるのはdeltaの取得です。
これまたブラウザごとに違うのでどれでも取れるようにします。

移動量を計算しているlet pos = Math.floor(this._pos + (delta / 3))は一気に進むのを防ぐためにホイール量の何分の1を付与にするといい感じになります。
ここでは3分の1にしてますが4分の1でもいいでしょう。
これも実際にブラウザの挙動を見て各自決めてください。

マウスの場合はタッチと違ってムーブイベントがないため、あまりdurationを長くするとゆっくり動くので、0.1sを今回は指定しています。

イベントの実行

さて、ロジックはできました。
しかし、このままではイベントは実行できません。
なので、イベントを呼び出せるようにします。

イベントの有効化

activate () {
  // 子要素が親要素より小さい場合はイベントを発行しない
  if (this._box.h <= this._container.h) return false

  this._ev = {}

  this._swipe()
  this._wheel()
}

イベントはactivateを呼び出すことで実行できるようにします。
この際に、子要素が親要素の高さより小さいとイベントを実行する意味がないのでイベントの登録を行わないようにします。

Let’s 呼び出し

さて、インスタンスもできたので呼び出しましょう!

const sc = new Scroll('container', 'box')
sc.activate()

呼び出したいところでインスタンスを生成し、activateを呼び出します。
ここでは続けて書いていますが、場合によってはインスタンスを生成だけしておいて、何らかの処理を挟んで呼び出すということも考えられます。

これでスクロールができました。

リサイズ

ここまででも十分ですが、リサイズすることを考えておきます。
サンプルでは768pxを起点に子要素の高さが変わります。

リサイズする時に何をするか。
イベントの破棄、情報の再取得・初期化、イベントの再セットを行う必要があります。

このうち、情報の再取得・初期化、イベントの再セットはすでに説明した_setup(), _swipe(), _wheel()が該当します。

ということでここではイベントの破棄を説明します。

イベントの破棄

deactivate () {
  // イベントを生成していない場合は処理しない
  if (this._ev === undefined) return false

  const elem = this._container.elem
  elem.removeEventListener($pointer.start, this._ev.start, false)
  elem.removeEventListener($pointer.move, this._ev.move, { passive: false })
  elem.removeEventListener($pointer.end, this._ev.end, false)
  elem.removeEventListener($wheel, this._ev.wheel, { passive: false })

  // イベントの初期化
  delete this._ev
}

イベントの破棄/有効化はリサイズに関係なく呼び出せるようにしておきます。
イベントだけ破棄をしたいということもありますからね。

そもそもイベントが発生していない場合はエラーになるので処理を行いません。

リサイズの処理

リサイズ時に気をつけなければ行けないのがモバイルです。
単純にリサイズイベントでそのままイベントを走らせてしまうと実行したくないところでも実行されてしまいます。

モバイルの場合、スクロールするとステータスバーなどが小さくなるのを見たことがあると思います。
実は、このステータスバーが小さくなった時にもリサイズイベントが走ります。
これは表示領域が広がるためにリサイズとみなされます。

そこで、リサイズが走ったとしても横幅が変わらない場合は、イベントを走らせないという処理をいれます。

update () {
  // ブラウザの幅を取得
  const w = document.documentElement.clientWidth || document.body.clientWidth

  // モバイル対策widthが変わっていた時に再セットアップ&アクティベート
  if (w !== this._windowSize) {
    this.deactivate()

    // 現在の横幅を記録する
    this._windowSize = w

    this._setup()
    this.activate()
  }
}

他にもorientationchangeイベントを使うという手もありますが、今回はこの方法を採用。
これで準備は完了したので、リサイズ処理を登録します。
上記の横幅をチェックするロジックはクラス内でなくてもリサイズイベントで処理してもOKです。

window.addEventListener('resize', () => {
  sc.update()
})

これで完成です。

まとめ

いかがでしたでしょうか?
独自で作るとなるといろいろ大変ですが、仕事では既存のライブラリでは対応しきれないこともあります。
そんな時に自分で作れるようになっていればどこを変更すればいいのか?独自で作ったほうがいいのかなどもわかってきます。

なので、一度挑戦してみてください。

今回はオーソドックスなスクロール処理でしたが、次は少しトリッキーな方法を紹介する予定ですのでお楽しみに!

最後に、今回のサンプルを貼り付けておきます。

See the Pen scroll-2 by Nobuyuki Kondo (@artprojectteam) on CodePen.0