概要
SlackやLINE、ChatGPTのようなチャットのUIでは最新のメッセージが最下部になっていることが多いです。
上記のUIでは新規のメッセージが追加されたとき、元のスクロール位置が最下部にあればメッセージが追加された後もスクロール位置が最下部になり、それ以外の場合はスクロール位置を維持する仕様になっています。
この機能をVue.jsで実装したので、実装方法を記載します。
完成品
Vue3(Composition API)で作成しました。
以下のリンク先でコードとプレビューを確認できます。
Vue.jsの機能はあまり使用していないため、他のフレームワークへの移行も比較的簡単です。
実装方法
以下の流れで実装します。
- メッセージエリアの作成
- 最下部へのスクロール
- 最下部にスクロールしているかの判定
メッセージエリアの作成
まずは、メッセージを表示する領域とメッセージを追加するためのボタンを用意します。
<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にアクセスできるようになります。
最下部へのスクロール
次にメッセージエリアにメッセージが追加されたらスクロール位置を最下部に変更する処理を実装します。
<script setup lang="ts">
...
onMounted(() => {
const observer = new MutationObserver((mutation) => {
messageAreaElement.value.scrollTo({
top: 10000
behavior: "smooth",
});
});
observer.observe(messageAreaElement.value, {
childList: true
});
});
</script>
MutationObserver
をchildList: true
として呼び出すと、DOMに要素が追加されたことを検知できます。
scrollTo()
を使うとスクロール位置を変更できます。
スクロール位置はtop:スクロールするピクセル数
で指定するのですが、最大値を超えていたらスクロールできる最大値に設定されるため、ここでは仮に大きめな値を入れてあります。(後で正しい値に修正します。)
behavior: "smooth"
とするとスクロール動作が滑らかになります。
最下部にスクロールしているかの判定
最下部にスクロールしているかの判定に必要な変数を用意します。
<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
の変更を検知します。
次にスクロール位置が最下部になっているかの判定をします。
<script setup lang="ts">
...
const isScrollAtBottom = computed(() => {
return (
Math.abs(scrollHeight.value - clientHeight.value - scrollTop.value) < 1
);
});
...
</script>
理論上はscrollHeight - clientHeight - scrollTop
が0
のときにスクロール位置が最下部になります。
===
で判定していないのは、scrollTop
は小数を含む可能性があるのに対して、scrollHeight
とclientHeight
は整数に丸められるため、スクロール量が閾値に十分に近いかで判定する必要があるからです。1
あとはメッセージ追加時にisScrollAtBottom
がtrueのときだけスクロール位置を最下部にする処理を実行するようにします。
<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>
完成
全体のコードは以下のようになります。
<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を使った実装もありかもしれません。
参考
- MDN Web Docs | Element.scrollTo()
- MDN Web Docs | Element.clientHeight
- MDN Web Docs | Element.scrollHeight
- MDN Web Docs | Element: scrollTop
- MDN Web Docs | scroll イベント
- MDN Web Docs | ResizeObserver
- MDN Web Docs | MutationObserver