面談記録管理アプリ開発:チャットのスクロールの実装
はじめに
前回はチャット機能の解説を行いました!
前回の記事はこちら↓
前回解説したところまでだと、チャットをスクロールして遡ることができないので、今回はチャットのログを遡る機能を実装していきます。
コントローラーの修正
まず、MessageController
にloadOlder
メソッドを実装していきます。
public function loadOlder(Message $message)
{
$messages = Message::where('created_at', '<', $message->created_at)
->where('meeting_logs_id', '=', $message->meeting_logs_id)
->latest()
->paginate(10);
return MessageResource::collection($messages);
}
まず、フロントエンドから表示しているメッセージの中で一番古いメッセージを受け取ります。
次に、そのメッセージ$message
の作成日時created_at
を参照して、その日時よりも過去のメッセージでかつ表示している面談記録のメッセージあるものを降順にして10件のページネーションとして取得しています。
そして、MessageResource
に沿ったデータの形にしてフロントエンドに返します。
ルーティング
続いて、ルーティングの設定をしていきます。
Route::get('/message/older/{message}', [MessageController::class, 'loadOlder'])
->name('message.loadOlder');
フロントエンドの実装
そして、フロントエンド側を実装していきます。
該当ソースコード全文
export default function Show({ auth, meetingLog, messages }) {
const [noMoreMessages, setNoMoreMessages] = useState(false);
const [scrollFromBottom, setScrollFromBottom] = useState(0);
const loadMoreIntersect = useRef(null);
const messagesCtrRef = useRef(null);
//// 省略 ////
const loadMoreMessages = useCallback(() => {
if (noMoreMessages) {
return;
}
const firstMessage = localMessages[0];
axios
.get(route("message.loadOlder", firstMessage.id))
.then(({data}) => {
if(data.data.length === 0) {
console.log("No more Messages");
setNoMoreMessages(true);
return;
}
const scrollHeight = messagesCtrRef.current.scrollHeight;
const scrollTop = messagesCtrRef.current.scrollTop;
const clientHeight = messagesCtrRef.current.clientHeight;
const tmpScrollFromBottom = scrollHeight - scrollTop - clientHeight;
setScrollFromBottom(tmpScrollFromBottom);
setLocalMessages((prevMessages) => {
return [...data.data.reverse(), ...prevMessages];
});
})
}, [localMessages]);
useEffect(() => {
if(messagesCtrRef.current && scrollFromBottom !== null) {
messagesCtrRef.current.scrollTop =
messagesCtrRef.current.scrollHeight -
messagesCtrRef.current.offsetHeight -
scrollFromBottom;
}
if (noMoreMessages) {
return
}
const observer = new IntersectionObserver(
(entries) =>
entries.forEach(
(entry) => entry.isIntersecting && loadMoreMessages()
),
{
rootMargin: "0px 0px 250px 0px",
}
);
if(loadMoreIntersect.current) {
setTimeout(() => {
observer.observe(loadMoreIntersect.current);
}, 100);
}
return () => {
observer.disconnect();
}
},[localMessages]);
}
以下部分ごとに説明していきます。
各Stateとrefの宣言
const [noMoreMessages, setNoMoreMessages] = useState(false);
const [scrollFromBottom, setScrollFromBottom] = useState(0);
const loadMoreIntersect = useRef(null);
const messagesCtrRef = useRef(null);
各State
とref
の説明は以下の通りです。
変数名 | 説明 |
---|---|
noMoreMessages | 読み込むメッセージの有無を判別するためのフラグ |
scrollFromBottom | 現在のスクロール位置がメッセージリストの底からどのくらい離れているかを記録するState
|
loadMoreIntersect | スクロールが該当位置に達したかどうかを監視るうためのref オブジェクト |
messagesCtrRef | メッセージリスト全体をラップするDOM要素を参照して、スクロール位置を制御するためのref オブジェクト |
loadMoreIntersect
とmessageCtrRef
は以下の箇所に配置されています。
loadMoreIntersect
は、メッセージが存在していた場合のDOM要素を参照しており、messageCtrRef
はメッセージリスト全体をラップするdiv
タグでDOM要素を参照している。
<div
{/* messagesCtrRef */}
ref={messagesCtrRef}
className="flex-1 overflow-y-auto p-5 max-h-[400px]"
>
{/* {messages} */}
{localMessages.length === 0 && (
<div className="flex justify-center items-center h-full">
<div className="text-lg text-gray-500">
メッセージがありません
</div>
</div>
)}
{localMessages.length > 0 && (
<div className="flex-1 flex flex-col">
{/* loadMoreIntersect */}
<div ref={loadMoreIntersect}></div>
{localMessages.map((message) => (
<MessageItem
key={message.id}
message={message}
/>
))}
</div>
)}
</div>
loadMoreMessages
まず、loadMoreMessages
について解説します。
loadMoreMessages
は、過去のメッセージをバックエンド側から取得してくる関数です。
const loadMoreMessages = useCallback(() => {
}, [localMessages]);
useCallback
は、メモ化されたコールバック関数を作成するためのReactフックの一つです。
今回の場合は、localMessages
が変更された場合、定義された関数が再生成されます。
変更されていない場合は、キャッシュされた関数が返ってきます。
これは、無駄な再生成を省くことで、パフォーマンスの改善が見込めます。
useCallback
の処理内容は以下で説明します。
まず、読み込むメッセージが存在するかどうかを判別するStatenoMoreMessages
がtrue
だった場合、処理を終了します。
if (noMoreMessages) {
return;
}
次に、現在読み込んでいるメッセージリストの先頭のメッセージ、すなわち最も古いメッセージをfirstMessage
に代入します。
const firstMessage = localMessages[0];
そして、axios
ライブラリを用いて、バックエンドに対してget
リクエストをfirstMessage
とともに送信します。
axios.get(route("message.loadOlder", firstMessage.id))
get
リクエストのレスポンスを基に、実行したい処理をthen
の中で記述していきます。
レスポンスデータは引数であるdata
オブジェクトに入ります。
.then(({data}) => {
})
以下、then
の中の処理を解説していきます。
まず、data
の中身がない場合、すなわちlength
が0
の場合、setNoMoreMessages
を使ってnoMoreMessages
ステートの値をtrue
にして処理を終了します。
if(data.data.length === 0) {
console.log("No more Messages");
setNoMoreMessages(true);
return;
}
次に、メッセージリストの底からスクロール現在地までの距離を求めて、scrollFromButtom
ステートに値を記録しておきます。
つまり、リスト全体からどのくらいの高さまでスクロールされているのかを保存しています。
const scrollHeight = messagesCtrRef.current.scrollHeight;
const scrollTop = messagesCtrRef.current.scrollTop;
const clientHeight = messagesCtrRef.current.clientHeight;
const tmpScrollFromBottom = scrollHeight - scrollTop - clientHeight;
setScrollFromBottom(tmpScrollFromBottom);
上記の計算式がよくわからなかったので調べてみました。
二つの記事を参考にして、なんとなく理解できたと思います。
特に、二つ目のサイトの図が参考になりました!
そして、setLocalMessages
を用いて、レスポンスとして帰ってきたデータ、すなわちバックエンドから取得した過去10件分のメッセージリストをlocalMessages
に追加します。
setLocalMessages((prevMessages) => {
return [...data.data.reverse(), ...prevMessages];
});
useEffect
続いて、該当するuseEffect
について解説します。
useEffect
とは、ターゲットとなっている変数が更新された場合、再レンダリングとともに内部に記述された処理を実行するフックです。
今回は、localMessages
の値が更新されたとき、すなわちかチャット送受信時や過去のメッセージの読み込み時に、実行されるフックになります。
useEffect(() => {
},[localMessages]);
さらに、メッセージリスト全体をラップするDOMが存在し、かつスクロールの位置が設定されている場合、スクロールの位置を調整します。
後述しますが、スクロールバーがチャット画面の上端付近に到達したかどうかを判定して、過去メッセージ取得のリクエストを飛ばしています。
そのため、スクロール位置を制御する処理を行わなければ、スクロールバーが常態にいる状態が維持され、読み込むメッセージがなくなるまで無制限に読み込み続けてしまいます。
if(messagesCtrRef.current && scrollFromBottom !== null) {
messagesCtrRef.current.scrollTop =
messagesCtrRef.current.scrollHeight -
messagesCtrRef.current.offsetHeight -
scrollFromBottom;
}
また、読み込むメッセージの有無を判定するnoMoreMessages
がtrue
だった場合、すなわち読み込むメッセージがなくなった場合、途中で処理を終了します。
if (noMoreMessages) {
return
}
続いて、Intersection Observer API
のインターフェースであるIntersectionObserver
をインスタンス化してobserver
に代入しています。
これは、スクロール位置を監視して、rootMargin
で設定した位置にスクロールされたときに、loadMoreMessages()
を実行します。
今回は、メッセージ表示部分の上端から下に250px
の位置にスクロールされたときに処理が実行されます。
const observer = new IntersectionObserver(
(entries) =>
entries.forEach(
(entry) => entry.isIntersecting && loadMoreMessages()
),
{
rootMargin: "0px 0px 250px 0px",
}
);
そして、observer.observe()
を用いて、loadMoreIntersect.current
を監視します。
if(loadMoreIntersect.current) {
setTimeout(() => {
observer.observe(loadMoreIntersect.current);
}, 100);
}
最後に、該当ページを離れるときにdisconnect()
を用いて監視を終了します。
return () => {
observer.disconnect();
}
以上で、無限スクロールができ、過去のメッセージ履歴を逐次閲覧する仕組みができました!
おわりに
今回は、チャットのスクロール機能について解説しました。
作成したアプリにはほかにも機能を付けていますが、後は基本的なCRUD処理を行っているだけなので、GitHubのソースコードを参照いただければと思います。
また、要望いただくなど、執筆するモチベーションになる出来事があれば、追加で書いていきたいと思います。
次回は、作成したアプリをデプロイした方法を解説した記事を書いていきます!
ではでは!
Discussion