1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【TypeScript/Vue.js】チャットUIによくあるメッセージ送受信時にスクロール位置を最下部に維持する処理を実装する

Last updated at Posted at 2023-04-14

概要

SlackやLINE、ChatGPTのようなチャットのUIでは最新のメッセージが最下部になっていることが多いです。
上記のUIでは新規のメッセージが追加されたとき、元のスクロール位置が最下部にあればメッセージが追加された後もスクロール位置が最下部になり、それ以外の場合はスクロール位置を維持する仕様になっています。

Vue-SFC-Playground.gif

この機能をVue.jsで実装したので、実装方法を記載します。

完成品

Vue3(Composition API)で作成しました。
以下のリンク先でコードとプレビューを確認できます。

Vue.jsの機能はあまり使用していないため、他のフレームワークへの移行も比較的簡単です。

実装方法

以下の流れで実装します。

  1. メッセージエリアの作成
  2. 最下部へのスクロール
  3. 最下部にスクロールしているかの判定

メッセージエリアの作成

まずは、メッセージを表示する領域とメッセージを追加するためのボタンを用意します。

App.ts
<script setup lang="ts">
const messages = ref<Array<string>>([]);
const addMessage = (text: string) => {
  messages.value.push(text);
};

const messageAreaElement = ref<HTMLElement>();
</script>

<template>
  <div ref="messageAreaElement" class="message-area">
    <div v-for="message in messages">
      <div>
        {{ message }}
      </div>
    </div>
  </div>
  <button @click="addMessage('メッセージ')">メッセージ追加</button>
</template>

<style scoped>
.message-area {
  width: 240px;
  height: 20vh;
  border: 1px solid;
  overflow-y: scroll;
}
</style>

refオブジェクトを宣言したあと、操作したいDOM要素のref属性に宣言した変数を渡すとDOMにアクセスできるようになります。

最下部へのスクロール

次にメッセージエリアにメッセージが追加されたらスクロール位置を最下部に変更する処理を実装します。

App.ts
<script setup lang="ts">
...
onMounted(() => {
  const observer = new MutationObserver((mutation) => {
    messageAreaElement.value.scrollTo({
      top: 10000
      behavior: "smooth",
    });
  });
  observer.observe(messageAreaElement.value, {
    childList: true
  });
});
</script>

MutationObserverchildList: trueとして呼び出すと、DOMに要素が追加されたことを検知できます。

scrollTo()を使うとスクロール位置を変更できます。
スクロール位置はtop:スクロールするピクセル数で指定するのですが、最大値を超えていたらスクロールできる最大値に設定されるため、ここでは仮に大きめな値を入れてあります。(後で正しい値に修正します。)
behavior: "smooth"とするとスクロール動作が滑らかになります。

最下部にスクロールしているかの判定

最下部にスクロールしているかの判定に必要な変数を用意します。

App.ts
<script setup lang="ts">
...
const clientHeight = ref(0);
const scrollHeight = ref(0);
const scrollTop = ref(0);

onMounted(() => {
  messageAreaElement.value.addEventListener("scroll", (event: Event) => {
    scrollTop.value = event.target.scrollTop;
  });

  const resizeObserver = new ResizeObserver((entries) => {
    const target = entries[0]?.target;
    clientHeight.value = target.clientHeight;
  });
  resizeObserver.observe(messageAreaElement.value);

  const observer = new MutationObserver((mutation) => {
    scrollHeight.value = newScrollHeight;
  });
});
</script>

clientHeightは見た目上の要素の高さです。
resizeObserverを使うことによってclientHeightの変更を検知します。

scrollHeightはoverflowしていて画面上に表示されない部分を含めた要素の中身の高さです。
MutationObserverを使うことによって、メッセージエリアに要素が追加されたときのscrollHeightの変更を検知します。

scrollTopは垂直方向にスクロールされている距離です。
scrollイベントが発生したときにscrollTopの変更を検知します。

次にスクロール位置が最下部になっているかの判定をします。

App.ts
<script setup lang="ts">
...
const isScrollAtBottom = computed(() => {
  return (
    Math.abs(scrollHeight.value - clientHeight.value - scrollTop.value) < 1
  );
});
...
</script>

理論上はscrollHeight - clientHeight - scrollTop0のときにスクロール位置が最下部になります。
===で判定していないのは、scrollTopは小数を含む可能性があるのに対して、scrollHeightclientHeightは整数に丸められるため、スクロール量が閾値に十分に近いかで判定する必要があるからです。1

あとはメッセージ追加時にisScrollAtBottomがtrueのときだけスクロール位置を最下部にする処理を実行するようにします。

App.ts
<script setup lang="ts">
...
onMounted(() => {
  ...
  const observer = new MutationObserver((mutation) => {
    const newScrollHeight = mutation[0].target.scrollHeight;
    if (isScrollAtBottom.value) {
      messageAreaElement.value.scrollTo({
        top: newScrollHeight - clientHeight.value,
        behavior: "smooth",
      });
    }
    ...
});
</script>

完成

全体のコードは以下のようになります。

App.ts
<script setup lang="ts">
import { ref, watch, computed, onMounted } from "vue";

const messages = ref<Array<string>>([]);
const addMessage = (text: string) => {
  messages.value.push(text);
};

const messageAreaElement = ref<HTMLElement>();
const clientHeight = ref(0);
const scrollHeight = ref(0);
const scrollTop = ref(0);

const isScrollAtBottom = computed(() => {
  return (
    Math.abs(scrollHeight.value - clientHeight.value - scrollTop.value) < 1
  );
});

onMounted(() => {
  messageAreaElement.value.addEventListener("scroll", (event: Event) => {
    scrollTop.value = event.target.scrollTop;
  });

  const resizeObserver = new ResizeObserver((entries) => {
    const target = entries[0]?.target;
    clientHeight.value = target.clientHeight;
  });
  resizeObserver.observe(messageAreaElement.value);

  const observer = new MutationObserver((mutation) => {
    const newScrollHeight = mutation[0].target.scrollHeight;
    if (isScrollAtBottom.value) {
      messageAreaElement.value.scrollTo({
        top: newScrollHeight - clientHeight.value,
        behavior: "smooth",
      });
    }
    scrollHeight.value = newScrollHeight;
  });
  observer.observe(messageAreaElement.value, { childList: true, subtree: true });
});
</script>

<template>
  <div ref="messageAreaElement" class="message-area">
    <div v-for="message in messages">
      <div>
        {{ message }}
      </div>
    </div>
  </div>
  <button @click="addMessage('メッセージ')">メッセージ追加</button>
</template>

<style scoped>
.message-area {
  width: 240px;
  height: 20vh;
  border: 1px solid;
  overflow-y: scroll;
}
</style>

最後にscrollTo({top: 10000})topの指定をscrollHeight - clientHeightとしました。

補足

  • 最初はMutation Observerが発火したときに最下部にスクロールされているか判定をしようとしていました。
    しかし、Mutation Observerが発火している時点でscrollHeighは増加しているため、常にスクロール位置が最下部ではない判定になってしまうことに気付いたため、今回の実装になりました。

  • DOMの追加はDOMNodeInsertedでも検知できるようですが、こちらは現在deprecatedのようです。2

  • ここでは記載しませんが、仕様によってはIntersection Observer APIを使った実装もありかもしれません。

参考

  1. Element.scrollHeight | 要素が完全にスクロールされたかどうかの判定

  2. W3C | DOMNodeInserted

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?