채팅 기능을 적용하면서 채팅이 쌓이다보면 스크롤바가 생기게 된다.
그러면 이후 채팅방에 들어가게 되면 마지막 메시지가 아닌 위의 사진처럼 처음 메시지부터 시작하게 된다.
때문에, 채팅방에 들어간 순간, 스크롤바를 제일 아래 end
로 보낸 후, 스크롤바를 올리면 새로운 메시지를 얻어오도록 해야 한다.
(메시지는 mock 데이터이고, 적을게 없어서 아무거나 적었다.)
우선, 스크롤바가 적용되는 컴포넌트의 상위에 ref를 추가해준다.
저 listRef로 스크롤바를 제어할 것이다.
const listRef = useRef<HTMLDivElement>(null);
const hasMessage = data?.pages[0].chats.length > 0;
useEffect(() => {
if (hasMessage) {
listRef.current?.scrollIntoView({ block: 'end' });
setPageRendered(true);
}
}, [hasMessage]);
스크롤이 가장 위로 올라가면 그때 새로운 데이터를 얻어와야 하기에 리버스 인피니트 스크롤을 적용한다.
얻어온 data의 첫 페이지에 message 데이터가 존재하면 읽고 있던 위치로 스크롤바를 아래로 내려주도록 한다.
원래는 데이터를 얻게 되면 얻은 데이터의 최상단으로 스크롤바가 올라가게 된다.
이를 읽던 위치로 스크롤바를 되돌려주는 것이다.
스크롤바가 자연스럽게 내려가게 하고 싶다면,
scrollIntoView
속성에 behavior: 'smooth'
를 추가해주면 된다.
listRef.current?.scrollIntoView({ block: 'end', behavior: 'smooth' });
그럼 스크롤바가 천천히 내려가는걸 볼 수 있다.
난 첫 바로바로 내려가는게 사용하기 편할 것 같아 사용하지 않았다.
메시지를 전송한 후에도 가장 최근 메시지를 볼 수 있도록 스크롤바가 가장 하단에 위치해야 한다.
그렇기 위해선 메시지 전송 시 스크롤바를 end
로 수정해주어야 한다.
메시지 입력 컴포넌트와 메시지 리스트 컴포넌트가 한 곳에 있지 않기 때문에 전역 상태로 관리해주었다.
// src/store/message.ts
import { create } from 'zustand';
interface MessageState {
shouldGoDown: boolean;
setGoDown(bool: boolean): void;
reset(): void;
}
// eslint-disable-next-line import/prefer-default-export
export const useMessageStore = create<MessageState>((set) => ({
shouldGoDown: false,
setGoDown(bool) {
set({ shouldGoDown: bool });
},
reset() {
set({
shouldGoDown: false,
});
},
}));
스크롤바를 내릴 것인지에 대한 상태를 저장하는 store다.
이 상태를 이제 메시지 입력 컴포넌트에서 불러와주다.
// src/app/(chat)/_components/molecules/MessageInput.tsx
const {setGoDown} = useMessageStore();
const sendMessage = () => {
if (message.trim().length < 1) return;
// TODO: 메시지 소켓 전송
setMessage('');
setGoDown(true);
};
이제 상태가 변경됨을 감지하면 메시지 리스트의 스크롤바를 최하단으로 내려줘야 한다.
메시지 리스트 컴포넌트로 이동하여 아까의 listRef
에 똑같이 scrollIntoView
를 사용해주자.
// src/app/(chat)/_components/molecules/Message.tsx
useEffect(() => {
if (shouldGoDown) {
listRef.current?.scrollIntoView({ block: 'end' });
setGoDown(false);
}
}, [shouldGoDown, setGoDown]);
그럼 메시지를 전송한 후에 스크롤바가 최하단으로 내려가는 것을 볼 수 있다.
스크롤바를 제어하기 위해선 listRef의 scrollHeight, scrollTop을 알아야 한다.
(이미지 https://devbirdfeet.tistory.com/228)
scrollHeight는 요소의 전체 콘텐츠 높이를 나타낸다.
즉, 요소 내부의 콘텐츠가 스크롤 없이 모두 표시될 경우의 높이이다.
scrollHeight는 콘텐츠가 요소의 현재 크기보다 클 경우, 즉 스크롤이 필요한 경우 유용하다.
scrollTop은 스크롤 가능한 요소의 수직 스크롤 위치를 나타낸다.
사용자가 스크롤을 내리면 scrollTop 값이 증가하고, 스크롤을 올리면 감소한다.
이 scrollTop 값을 설정하여 스크롤 위치를 변경할 수 있다.
두 값으로 옮길 스크롤바의 위치를 계산한다.
// src/app/(chat)/_components/molecules/Message.tsx
useEffect(() => {
if (inView && hasPreviousPage && !isFetching && !adjustScroll) {
const prevHeight = listRef.current?.scrollHeight || 0;
setAdjustScroll(() => true);
fetchPreviousPage()
.then(() => {
setTimeout(() => {
setCnt((prev) => prev + 1);
if (listRef.current) {
const moveScroll = listRef.current.scrollHeight - prevHeight;
listRef.current.scrollTop = moveScroll;
}
setAdjustScroll(false);
}, 0);
});
}
}, [inView, fetchPreviousPage, isFetching, hasPreviousPage, adjustScroll, cnt]);
과정은 다음과 같다.
이전 높이 저장
새 페이지를 불러오기 전에 preHeight
변수에 scrollHeight
값을 저장한다.
새 페이지 불러오기
fetchPreviousPage
를 호출하여 메시지를 불러온다.
조정할 스크롤 크기 계산
새 메시지를 불러온 후 커진 listRef
의 scrollHeight
에서 이전에 계산해 놓은 prevHeight
를 빼주어 증가한 값을 얻어온다.
listRef의 스크롤바 위치 조정
3번에서 계산한 값을 listRef의 scrollTop으로 변경해준다.
여기서 adjustScroll
과 setAdjustScroll
이 추가되었는데, 스크롤을 조정하고 있는 동안에는 새 메시지를 얻어오지 못하게 막기 위해서이다.
// src/app/(chat)/_components/molecules/Message.tsx
const [adjustScroll, setAdjustScroll] = useState(false);
...
return (
<div ref={listRef} className="overflow-y-scroll flex-1">
{!adjustScroll && pageRendered && <div ref={ref} className="h-1" />}
{data?.pages.map((page) => (
...
상태를 추가해주고, 스크롤을 조정중일 땐 새로운 메시지를 얻어오도록 감시하는 inView 요소가 출력되지 않도록 제어해준다.
(영상 올릴 때 원래 이렇게 느려지는건지 모르겠다)
스크롤 제어도 끝!
이제 소켓 연결하자!