• #Vue.js
  • #scrollIntoView
  • #DOM操作
  • #UI/UX
  • #書籍ビューア
開発book-knowledge-baseメモ

書籍ビューアのスクロールカクつき修正

book-knowledge-baseの書籍ビューアで、矢印キーでページを切り替えるときに中カラムが一瞬トップにジャンプしてから選択ページまでスクロールする「カクつき」が起きていた。原因は scrollIntoView が親要素のスクロール位置にまで影響していたこと。scrollTop を直接制御する方式に切り替えて解消した。

問題の症状

書籍ビューアは3カラム構成になっている。

  • 左カラム: 書籍一覧
  • 中カラム: 選択中の書籍のページ一覧(目次的な役割)
  • 右カラム: ページの本文

矢印キーの上下でページを順番に切り替えられる機能があるが、中カラムのページ一覧でページ数が多い書籍を閲覧しているとき、次のような動きになっていた。

  1. 矢印キーを押す
  2. 中カラムが一番上までスクロールされる
  3. その後、選択されたページ位置までスクロールが走る

この1→2→3の動きが目に見えるため、ページを切り替えるたびに画面がガタガタと揺れる。特にページ番号が大きい(リストの下の方にある)場合に顕著だった。

原因: scrollIntoViewの親要素への波及

修正前のコードでは、選択中のページ要素を画面内に表示するために scrollIntoView を使っていた。

// 修正前: scrollIntoView を使用
const scrollToSelectedPage = () => {
  const selectedElement = document.querySelector('.page-item.selected')
  if (selectedElement) {
    selectedElement.scrollIntoView({
      behavior: 'smooth',
      block: 'nearest'
    })
  }
}

scrollIntoView は対象要素が見える位置に来るまで、その要素の全ての祖先スクロールコンテナをスクロールする。つまり、中カラムだけでなく、その親要素やさらにその上の要素のスクロール位置も変更してしまう。

今回のケースでは、中カラムの親に当たるレイアウト要素もスクロール可能だったため、scrollIntoView が親要素のスクロール位置をリセットしてしまっていた。その後、ブラウザが中カラム内の対象要素までスクロールするので、「一旦上に飛んでから戻ってくる」という動きになっていた。

scrollIntoView の仕様上の注意点

MDNのドキュメントにも記載があるが、scrollIntoView は対象要素が画面内に表示されるよう、スクロール可能な全ての祖先要素をスクロールする。block: 'nearest' を指定しても、この挙動自体は変わらない。nearest は「最小限のスクロール」を意味するが、それは各スクロールコンテナごとに独立して計算されるため、複数のスクロールコンテナがネストしている場合に意図しない動きになりやすい。

修正方法: scrollTop の直接制御

scrollIntoView の代わりに、中カラムのコンテナ要素の scrollTop を直接計算して設定する方式に変更した。

1. テンプレートにrefを追加

まず、中カラムのスクロールコンテナにテンプレートrefを設定する。

<template>
  <div ref="pageListContainer" class="page-list-container">
    <div
      v-for="page in pages"
      :key="page.id"
      :class="['page-item', { selected: page.id === selectedPageId }]"
      :data-page-id="page.id"
      @click="selectPage(page.id)"
    >
      {{ page.title }}
    </div>
  </div>
</template>
const pageListContainer = ref<HTMLElement | null>(null)

2. scrollTop を直接計算して設定

scrollIntoView を使わず、コンテナの scrollTop を直接制御する関数に書き換えた。

// 修正後: scrollTop を直接制御
const scrollToSelectedPage = () => {
  const container = pageListContainer.value
  if (!container) return

  const selectedElement = container.querySelector('.page-item.selected') as HTMLElement
  if (!selectedElement) return

  const containerRect = container.getBoundingClientRect()
  const elementRect = selectedElement.getBoundingClientRect()

  // 選択要素がコンテナの表示範囲外にある場合のみスクロール
  if (elementRect.top < containerRect.top) {
    // 要素がコンテナの上に隠れている場合: 要素が上端に来るようスクロール
    container.scrollTop += elementRect.top - containerRect.top
  } else if (elementRect.bottom > containerRect.bottom) {
    // 要素がコンテナの下に隠れている場合: 要素が下端に来るようスクロール
    container.scrollTop += elementRect.bottom - containerRect.bottom
  }
  // 表示範囲内なら何もしない
}

この方式のポイントは3つ。

  • コンテナのscrollTopだけを変更する: 親要素のスクロール位置には一切触れない
  • 必要なときだけスクロールする: 選択要素が既に表示範囲内ならスクロールしない
  • 最小限のスクロール量: 上に隠れていれば上端まで、下に隠れていれば下端までの差分だけスクロールする

3. 矢印キーハンドラからの呼び出し

キーボードイベントハンドラで、ページID更新後に nextTick でスクロール処理を呼ぶ。

const handleKeyDown = (event: KeyboardEvent) => {
  if (event.key === 'ArrowDown') {
    event.preventDefault()
    const nextPage = getNextPage(selectedPageId.value)
    if (nextPage) {
      selectedPageId.value = nextPage.id
      nextTick(() => scrollToSelectedPage())
    }
  } else if (event.key === 'ArrowUp') {
    event.preventDefault()
    const prevPage = getPrevPage(selectedPageId.value)
    if (prevPage) {
      selectedPageId.value = prevPage.id
      nextTick(() => scrollToSelectedPage())
    }
  }
}

nextTick を挟む理由は、selectedPageId の変更がDOMに反映された後に .selected クラスの付いた要素を探す必要があるため。nextTick がないと、まだ前のページに .selected が付いた状態で位置を計算してしまう。

getBoundingClientRect vs offsetTop

scrollTop を直接制御する方法には、getBoundingClientRect を使う方法と offsetTop を使う方法がある。

// 方法A: getBoundingClientRect(今回採用)
const containerRect = container.getBoundingClientRect()
const elementRect = selectedElement.getBoundingClientRect()
container.scrollTop += elementRect.top - containerRect.top

// 方法B: offsetTop
container.scrollTop = selectedElement.offsetTop - container.offsetTop

今回は方法Aを採用した。getBoundingClientRect はビューポート座標を返すので、現在のスクロール位置に対する相対的な差分を計算しやすい。方法Bの offsetTopoffsetParent に対する相対位置なので、offsetParent がコンテナ自体であることを前提にしている。CSSの position 設定次第では offsetParent が想定と異なる要素になる場合がある。

動作確認

矢印キーでページ25 → 26 → 27 → 29と連続して切り替えるテストを行った。

確認した点:

  • 中カラムが一番上にジャンプしない
  • 選択ページがリストの下にある場合でも、スムーズに追従する
  • 選択ページが既に表示範囲内にあるときは、不要なスクロールが発生しない
  • 上方向への移動(29 → 27 → 26 → 25)でも同様にスムーズ

いずれのケースでもカクつきは発生せず、意図通りの動作になった。

まとめ

scrollIntoView は手軽だが、ネストしたスクロールコンテナがある場合に親要素のスクロール位置まで変えてしまう副作用がある。今回の修正では、対象コンテナの scrollTop を直接計算・設定することで、スクロール範囲を中カラムだけに限定した。

修正のポイント:

  • テンプレートrefでコンテナ要素を取得
  • getBoundingClientRect でコンテナと対象要素の位置関係を計算
  • 表示範囲外のときだけ、差分をscrollTopに加算
  • nextTick でDOM反映後にスクロール処理を実行