안녕하세요 여러분!
길고도 짧았던 2학기 첫 프로젝트가 마무리되었습니다.
저는 첫 프로젝트로 WebRTC를 활용한 커플 다이어리 서비스를 제작하게 되었는데요
커플 서비스인만큼 채팅 기능을 빼놓을 수 없는 상황!
예상보다 고전했던 '신규 채팅 수신 시 화면 가장 아래로 이동' 기능을 만들기 위한 채팅방 UI/UX 개선기를 공개합니다.
※ 아래 화면 및 소스코드는 실제 프로젝트 작업물이 아닌 기사를 위해 작성한 간이 소스코드입니다.
1. 신규 메세지 수신 시 화면 맨 밑으로 이동
메세지 수신 시 화면 가장 아래로 이동하는 기능은 JavaScript의 scrollTo와 scrollIntoView를 이용하여 간단하게 구현이 가능합니다.
// 메세지들을 감싸는 Wrapper ref
const scrollRef = useRef<HTMLDivElement>(null);
// 메세지 배열 (프로젝트 시에는 MessageInterface를 작성하였으나 본문에서는 string 배열로 대체)
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
//messages 배열에 변화가 있을 경우 scrollRef를 가장 아래로 내린다.
if (!scrollRef.current) return;
scrollRef.current.scrollIntoView({ block: "end" });
scrollRef.current.scrollTo(0, scrollRef.current.scrollHeight);
}, [messages])
return (
<>
<Wrapper ref={scrollRef}>
{messages.map((msg, idx) => (
<div className="msg" key={idx}>
{msg}
</div>
))}
</Wrapper>
</>
)
단, 위와 같이 구현할 경우 한 가지 문제가 있었는데, 바로 scrollRef의 길이가 짧아 아래와 같이 전송 버튼을 누르면 input section이 화면 아래로 내려가버리는 것!
처음 Input창이 올라갔을 때 화면을 스크롤해보면 키보드 뒷편이 비어있다는 것을 알 수 있는데요, scroll 메서드에 의해 해당 빈 공간이 다시 채워지게 된 것이었습니다.
그렇다면 Input창의 위치를 어떻게 키보드 위로 고정할 수 있을까요?
각종 모바일 웹의 채팅 페이지를 확인해본 결과, 많은 서비스에서 해당 현상을 막기 위해 input창 아래로 margin을 넣어주었다는 것을 알 수 있었습니다.
2. Input의 위치를 고정한 채로 채팅 전송하기
JavaScript에서 브라우저의 크기를 확인하기 위한 메서드로는 여러가지가 있는데요, 저는 그 중 화면 전체의 높이를 구할 수 있는 outerHeight와, 실제 표시되는 화면의 높이인 visualViewport.height를 활용하였습니다.
키보드가 나타나 visualViewport의 높이가 변경되면 input section의 아래에 margin을 넣어 키보드에 가려지는 영역을 높이는 것으로, 화면이 밑으로 내려가더라도 input창의 위치가 동일하게 보이도록 높이를 조정하는 방법입니다.
const [messages, setMessages] = useState<string[]>([]);
const [text, setText] = useState(""); // 입력한 메세지
const [isKeyup, setIsKeyup] = useState(false); // 키보드 on/off 확인
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); // input section ref
const bufferRef = useRef<HTMLDivElement>(null); // input 아래의 margin
// 메세지 송신 함수 (실제 구현 시 webSocket 함수 입력)
const sendChat = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (text == "") return;
setMessages((prev) => [...prev, text]);
setText("");
// 키보드가 켜져있는 것을 확인
setIsKeyup(true);
};
useEffect(() => {
//messages 배열에 변화가 있을 경우 scrollRef를 가장 아래로 내린다.
if (!scrollRef.current) return;
if (!visualViewport) return;
// 만일 scrollRef의 높이가 window.outerHeight - (핸드폰 기본 인터페이스의 대략적 높이) 보다 작을 경우
if (scrollRef.current.scrollHeight < window.outerHeight - 100) {
if (!isKeyup) return; // 키보드가 켜져있지 않은 상태라면 return
if (!bufferRef.current) return;
// scrollRef의 높이를 visualViewport.height - (입력창의 높이) 로 변경
scrollRef.current.style.setProperty(
"height",
`${visualViewport.height - window.outerHeight * 0.08}px`
);
// bufferRef의 높이를 window.outerHeight - visualViewport.height - (핸드폰 기본 인터페이스의 대략적 높이) 로 변경
bufferRef.current.style.setProperty(
"height",
`${window.outerHeight - visualViewport.height - 60}px`
);
}
scrollRef.current.scrollIntoView({ block: "end" });
scrollRef.current.scrollTo(0, scrollRef.current.scrollHeight);
}, [messages, isKeyup])
// 키보드가 내려가면 화면 리사이징
useEffect(() => {
if (isKeyup) return;
if (!scrollRef.current) return;
if (!bufferRef.current) return;
scrollRef.current.style.setProperty("height", "auto");
bufferRef.current.style.setProperty("height", "0px");
scrollRef.current.scrollTo(0, scrollRef.current.scrollHeight);
}, [isKeyup]);
return (
<>
<Wrapper
ref={scrollRef}
style={
scrollRef.current &&
scrollRef.current.scrollHeight < window.outerHeight - 100
? { paddingBottom: "2dvh" }
: undefined
}>
{messages.map((msg, idx) => (
<div className="msg" key={idx}>
{msg}
</div>
))}
</Wrapper>
<Footer onSubmit={sendChat}>
<div className="input">
<textarea
cols={30}
rows={1}
value={text}
ref={inputRef}
onChange={(e) => {
setText(e.target.value);
// 2줄 이상 입력 시 textarea의 사이즈 조정하는 함수
inputRef.current?.style.setProperty("height", "auto");
inputRef.current?.style.setProperty(
"height",
`${inputRef.current?.scrollHeight}px`
);
}}
onBlur={() => {
// focus out되면 키보드 끄기
setIsKeyup(false);
}}
></textarea>
<button
onMouseDown={(e) => {
// 키보드가 내려가지 않게 고정
e.preventDefault();
inputRef.current?.style.setProperty("height", "auto");
}}
>
전송
</button>
</div>
<div ref={bufferRef}></div>
</Footer>
</>
)
이제 신규 메세지를 보내거나 받으면 화면에 바로 표시할 수 있게 됩니다.
단, 이번에는 아래와 같이 화면을 scroll할 경우 margin이 추가된 input이 함께 움직이는 문제가 발생하였습니다.
3. Input Margin 스크롤 막기
저는 해당 문제를 막기 위해 아래와 같은 event listener를 추가해주었습니다.
const preventWheel = (e: TouchEvent) => {
const target = e.target as HTMLDivElement;
if (!scrollRef.current) return;
// touch한 target이 scrollRef가 아닐 경우 스크롤 방지
if (
!target.contains(scrollRef.current) &&
!target.classList.contains("msg")
) {
e.preventDefault();
}
// scrollRef에 채팅이 충분히 많지 않을 경우 스크롤 방지
if (scrollRef.current.scrollHeight <= scrollRef.current.clientHeight) {
e.preventDefault();
}
};
useEffect(() => {
...
window.addEventListener("touchmove", preventWheel, { passive: false });
...
}, [messages, isKeyup]);
useEffect(() => {
...
window.removeEventListener("touchmove", preventWheel);
...
}, [isKeyup]);
최종적으로 구현된 화면입니다.
채팅이 스크롤할만큼 많지 않을 경우 스크롤이 되지 않습니다!
이상, 모바일 채팅방 구현기였습니다.
감사합니다!
'SSAFYcial' 카테고리의 다른 글
[SSAFYcial] 이게 왜 안됨? ③이거 왜 적용 안됨? state 변화 타이밍 알아보기 (0) | 2024.05.22 |
---|---|
[SSAFYcial] Socket.io로 10분만에 채팅 만들기 (1) | 2024.02.25 |
[SSAFYcial] Zustand에 대해 알아보자 (1) | 2024.01.28 |
[SSAFYcial] 이게 왜 안됨? ①RequestParam name 오류 (2) | 2024.01.28 |
[SSA業탐구] 안드로이드 앱을 만드는 모바일 개발자편 (0) | 2023.12.24 |