Katashin .info

iOS のラバーバンドスクロールを Web で実装する方法

普段 iPhone を使っている人でスクロールが端に到達した時に、少しだけ端を越えていき、その後跳ね返ってくる挙動を意識したことがある人はどれだけいるでしょうか?その挙動をどう実装するか考えたことはありますか?

この iOS の挙動をラバーバンドスクロールバウンススクロールバウンスバックなどと呼びますが、ほとんどの人はあまり意識せずに iOS デバイスを使っていると思います。今では当たり前のこの挙動は、iOS の使っていて気持ちのいい UI に大きく寄与しています。

本記事では、この挙動をラバーバンド効果と呼び、単純化した例を通じてその実装方法を解説します。

ラバーバンド効果の単純化した例 #

ラバーバンド効果はスクロールだけではなく、移動可能なオブジェクトが動ける範囲を暗に示すために使えます。例えば、この記事では以下のような、200×200の範囲でドラッグできるオブジェクトにラバーバンド効果を実装します。

青いオブジェクトはドラッグで動かすことができ、白い部分はオブジェクトが移動可能な範囲です。グレーの部分までオブジェクトを引っ張ることもできますが、外に出るほど抵抗が強くなっていき、ドラッグを終えると白い範囲まで戻ります。

この実装を4つのステップに分割してラバーバンド効果の実装を解説します。

ラバーバンド効果の実装 #

オブジェクトをドラッグ可能にする #

ラバーバンド効果の実装の前段階として、ドラッグ可能なオブジェクトを実装します。ドラッグ対象のオブジェクト(target)のポインターイベントを監視し、ドラッグされた座標にオブジェクトを動かします。この段階ではオブジェクトが範囲外に出ないように clamp 関数で座標の値を補正しています。

// ドラッグ範囲の最小・最大座標
const minX = -100
const maxX = 100
const minY = -100
const maxY = 100

// ドラッグ対象のオブジェクト
const target = document.getElementById('target')

target.addEventListener('touchstart', prevent)
target.addEventListener('pointerdown', onPointerDown)
target.addEventListener('pointermove', onPointerMove)
target.addEventListener('pointerup', onPointerUp)
target.addEventListener('pointercancel', onPointerUp)
target.addEventListener('lostpointercapture', onPointerUp)

let isDragging = false

// ドラッグ開始時の座標を記録
let startX = 0
let startY = 0

// ドラッグ対象の現在の座標を記録
let targetX = 0
let targetY = 0

// value が min, max を越えたらその範囲に収まる値を返す
function clamp(min, value, max) {
  return Math.max(min, Math.min(value, max))
}

// 座標をドラッグ対象のオブジェクトに適用
function setOffset(x, y) {
  targetX = x
  targetY = y
  target.style.transform = `translate(${x}px, ${y}px)`
}

// モバイルでスクロールさせないようにする
function prevent(event) {
  event.preventDefault()
}

function onPointerDown(event) {
  isDragging = true
  startX = event.clientX - targetX
  startY = event.clientY - targetY
  target.setPointerCapture(event.pointerId)
}

function onPointerMove(event) {
  if (!isDragging) {
    return
  }

  // 指定した範囲内に収まるようにオブジェクトの座標をセット
  const x = clamp(minX, event.clientX - startX, maxX)
  const y = clamp(minY, event.clientY - startY, maxY)
  setOffset(x, y)
}

function onPointerUp(event) {
  if (!isDragging) {
    return
  }
  isDragging = false

  // 指定した範囲内に収まるようにオブジェクトの座標をセット
  const x = clamp(minX, event.clientX - startX, maxX)
  const y = clamp(minY, event.clientY - startY, maxY)
  setOffset(x, y)
}

HTML と CSS は本題とはあまり関係がないので省略しますが、以下からコードを読めます。

デモのHTMLとCSS
<!-- ドラッグ可能範囲を表示 -->
<div class="draggable-area"></div>
<!-- ドラッグ対象のオブジェクト -->
<div id="target"></div>
html {
  overflow: hidden;
  background-color: #ccc;
}

.draggable-area {
  position: absolute;
  top: 50%;
  left: 50%;
  margin: -125px 0 0 -125px;
  width: 250px;
  height: 250px;
  background-color: #fff;
}

#target {
  position: absolute;
  top: 50%;
  left: 50%;
  margin: -25px 0 0 -25px;
  width: 50px;
  height: 50px;
  background-color: #3012c7;
}

ここまでのコードを実行すると以下のようになります。

これだけでもドラッグされたオブジェクトを範囲外に出さないという要件を満たせますが、なんだか味気ないです。ここにラバーバンド効果を追加していきます。

範囲を越えたらラバーバンド効果を適用 #

まずは範囲外にドラッグした時に内側に引っ張るような挙動を実装します。pointermove のリスナー内で clamp していた部分を rubberBand 関数に書き換えます。

function onPointerMove(event) {
  if (!isDragging) {
    return
  }

  // clamp していた部分をラバーバンド効果に変更
  const x = rubberBand(event.clientX - startX, minX, maxX)
  const y = rubberBand(event.clientY - startY, minY, maxY)
  setOffset(x, y)
}

rubberBand 関数は以下のようになります。値が min または max を越えた場合は補正をかけることで引っ張るような挙動にします。実際の補正は rubber 関数で行われていて、この関数を調整することでさわり心地を変えられます。色々と試した結果、rubber 関数の実装は以下のコードのようにすると iOS のさわり心地に近くなります。

function rubberBand(value, min, max) {
  if (value < min) {
    // min を下回ったら下回った分を補正
    return min - rubber(min - value)
  }

  if (value > max) {
    // max を上回ったら上回った分を補正
    return max + rubber(value - max)
  }

  // min、max の範囲内ならば補正しない
  return value
}

function rubber(distance) {
  // デフォルトでどれだけ引っ張るか
  // 0 だと clamp と同じになる
  // 1 だと distance に応じた補正だけになる
  const rubberFactor = 0.85

  // distance の大きさに対してどれだけ引っ張るか
  // 0 だと distance に応じた補正を行わない
  // この値が大きくなるほど distance が大きくなった時の引っ張る補正が大きく働く
  const distanceFactor = 0.002

  // distance が大きくなればなるほど引っ張る力が大きくなるように式を組む
  return (distance * rubberFactor) / (distance * distanceFactor + 1)
}

ここまでのコードを実行すると以下のようになります。

ドラッグ中にラバーバンド効果が付き、範囲を越えた時の引っ張りを感じられるようになりましたが、ドラッグを終了した時にアニメーションせずに範囲内に戻ってしまうのを直したいところです。次はドラッグ終了時にアニメーションで範囲内に戻す実装をしましょう。

ドラッグを終了したらラバーバンド効果で範囲内に戻す #

pointerup のリスナーを書きかえ、アニメーションを開始する関数 startMomentum を呼び出します。

function onPointerUp(event) {
  if (!isDragging) {
    return
  }
  isDragging = false

  // ラバーバンド効果で範囲内に戻すアニメーションを開始
  startMomentum()
}

startMomentum 関数では requestAnimationFrame で rubberBand を繰り返し呼び出し、最終的にオブジェクトの座標が min もしくは max に十分近づいたらアニメーションを終了します。rubberBand 関数は範囲を越えた座標を内側に動かすので、繰り返し実行することで範囲内に引っ張るアニメーションになります。

function startMomentum() {
  let x = targetX
  let y = targetY

  function momentum() {
    const nextX = rubberBand(x, minX, maxX)
    const nextY = rubberBand(y, minY, maxY)
    setOffset(nextX, nextY)

    // 十分範囲に近づいたら終了する
    if (Math.abs(x - nextX) < 0.1 && Math.abs(y - nextY) < 0.1) {
      return
    }

    // x, y を更新し繰り返す
    x = nextX
    y = nextY
    requestAnimationFrame(momentum)
  }
  requestAnimationFrame(momentum)
}

ここまでのコードを実行すると以下のようになります。

これで完成と言いたいところですが、まだ終わりではありません。この実装は requestAnimationFrame で呼び出された時に前回の呼び出しからどれだけ経過したかを考慮していません。なので、モニターのリフレッシュレートが違う場合や、負荷が大きくてコマ落ちが発生している場合など、フレームレートの異なる環境で実行するとアニメーションの速度が変わってしまいます。次はこの問題に対処します。

フレームレートを考慮したラバーバンド効果 #

momentum 関数で経過時間を計算し、rubberBand 関数に渡します。今回は以前の実装からの変更を最小限にするために、経過時間の代わりに、期待するフレームレートを60とした場合の経過フレーム数を計算します(elapsedFrame)。

function startMomentum() {
  let x = targetX
  let y = targetY
  let lastTime

  function momentum(time) {
    // 初回実行時は lastTime を保存するだけにして繰り返す
    if (!lastTime) {
      lastTime = time
      requestAnimationFrame(momentum)
      return
    }

    // 期待するフレームレートを 60 として、前回の実行からの経過フレームを計算
    const elapsedFrame = ((time - lastTime) / 1000) * 60

    // ラバーバンド効果で座標を範囲に近づける
    // elapsedFrame を引数に渡し、経過フレームを考慮した補正を行う
    const nextX = rubberBand(x, minX, maxX, elapsedFrame)
    const nextY = rubberBand(y, minY, maxY, elapsedFrame)
    setOffset(nextX, nextY)

    // 十分範囲に近づいたら終了する
    if (Math.abs(x - nextX) < 0.1 && Math.abs(y - nextY) < 0.1) {
      return
    }

    // lastTime, x, y を更新し繰り返す
    lastTime = time
    x = nextX
    y = nextY

    requestAnimationFrame(momentum)
  }
  requestAnimationFrame(momentum)
}

rubberBand 関数、および、rubber 関数は以下のように変更します。rubber 関数の式が大きく変わっています。変更前の式を n - 1 番目の distance から n 番目の distance を求める漸化式とみなすことができるので、その一般項を求めるとこのような式になります。漸化式やその一般項を求める方法は数学の話になってしまうので、本記事では省略します。

// 引数に frame を追加し、経過フレームによって補正量を調整可能にする
function rubberBand(value, min, max, frame = 1) {
  if (value < min) {
    // min を下回ったら下回った分を補正
    return min - rubber(min - value, frame)
  }

  if (value > max) {
    // max を上回ったら上回った分を補正
    return max + rubber(value - max, frame)
  }

  // min、max の範囲内ならば補正しない
  return value
}

function rubber(distance, frame) {
  // デフォルトでどれだけ引っ張るか
  // 0 だと clamp と同じになる
  // 1 だと distance に応じた補正だけになる
  const rubberFactor = 0.85

  // distance の大きさに対してどれだけ引っ張るか
  // 0 だと distance に応じた補正を行わない
  // この値が大きくなるほど distance が大きくなった時の引っ張る補正が大きく働く
  const distanceFactor = 0.002

  // 以前の式を distance の漸化式とみなして一般項に書きかえたもの
  // 経過フレーム(frame)によって補正する量を調整することができる
  const a = 1 / rubberFactor
  const b = distanceFactor / (rubberFactor - 1)
  return 1 / ((1 / distance - b) * a ** frame + b)
}

ここまでのコードを反映した最終的なデモは以下のようになります。

実装方法の考察 #

ドラッグ終了時のアニメーションに関しては CSS Transition で行ったほうが楽ではないかという疑問を持つ人もいると思います。確かに rubber 関数の一般項を求めたり、requestAnimationFrame で毎回座標を計算する手間をかけるよりも、min、max の座標へ CSS Transition でアニメーションさせるほうが楽に実装できるし、JavaScript の計算が入らない分パフォーマンスも良くなるでしょう。

momentum 関数で行う処理がラバーバンド効果だけの場合はそれでも良いですが、より複雑なアニメーションを実現したい場合には JavaScript で行わざるを得ません。 例えば、オブジェクトのドラッグを終了した時に、ドラッグの向きに慣性を働かせ、すべらせるようなアニメーションを追加で実装する場合、すべっていったオブジェクトが範囲を越えた時にもラバーバンド効果を働かせることは CSS Transition にはできません。

また、ラバーバンド効果を見た時に、バネのような力を働かせて実装することを思いついた人もいるのではないでしょうか。つまり、requestAnimationFrame 内でフックの法則からオブジェクトの加速度と速度を計算し、それに基づいて毎フレーム座標を更新するやり方です。

これは一見うまくいくように見えますが、パラメーターの調整が難しいです。多くの場合 min または max へと引き戻す力が大きすぎてオブジェクトが通り過ぎてしまいます。その場合でも、その後振動しながら一点に収束しますが、これは求めているラバーバンドの挙動とは異なります。

本記事で解説した rubber 関数を使ったやり方だと、計算式によってオブジェクトが min または max を通り過ぎることなく、その一点にいつかは必ず収束するということが保証されているのが利点です。

おわりに #

ラバーバンド効果は実装するのにひと手間かかりますが、ユーザーの操作体験を損ねることなく、これ以上ドラッグすることができないということを暗に伝えることができます。機能的には clamp によってドラッグ可能な座標を越えないようにすることと変わりませんが、使って心地よいのはラバーバンド効果による実装のほうでしょう。

参考文献 #

Designing Fluid Interfaces - WWDC18 - Videos - Apple Developer
https://developer.apple.com/videos/play/wwdc2018/803/