PTR이란?
Pull To Refresh의 약자로 당겨서 새로고침을 뜻한다.
모바일에서 흔히 새로고침을 위해 터치스크린을 위에서 아래로 내려 새로고침 했던 경험이 있을 것이다.
현재 센텐스유의 포스트들을 새로고침 하기위해서 브라우저는 새로고침 버튼을 눌러야하고, PWA를 사용한 앱 사용자들은 다른페이지로 갔다오거나 앱을 종료했다가 다시 접속해야하는 번거로움이 있었다.
그래서 센텐스유에도 PTR을 적용하기로 했다.
react-pull-to-refresh
npm에 리액트용 ptr 라이브러리가 있다.
사용법도 간단해서 패키지를 설치해서 적용해보려 했으나 이상한 버그가 있었다.
초기 상태에서 당겨서 새로고침, 즉 PTR 기능은 잘 되었으나 나머지 요소들이 터치가 되지 않는 상황이었다.
PTR기능을 하는 view영역이 화면 전체를 덮고 있었는데 그 때문인 것 같았다.
또, 스크롤을 내리는 것 뿐만 아니라 올릴때도 reset모드에 들어가게 되어 요소가 움직이지 않는 상황이 발생했다.
그래서 결국 라이브러리 사용을 취소하고 직접 로직을 구현하기로 했다.
PTR(Pull To Refresh) 구현하기
처음에 몇가지 조건을 정해놨다.
- 메인 페이지, 포스트 페이지, 유저 페이지에서만 작동할 것
- 터치의 시작과 끝의 거리를 계산해서 구현하기
- 로딩중인 모습은 MUI의 CircularProgress로 통일하기
PTR 컴포넌트 생성
사용하고자 하는 컴포넌트에서 불러와 사용하기 위해 기능을 컴포넌트화 했다.
import React from 'react';
const PullToRefresh = () => {
...
};
export default PullToRefresh;
import PullToRefresh from '@components/PulltoRefresh';
state 생성
const [refreshing, setRefreshing] = useState(false);
const [startY, setStartY] = useState(0);
새로고침 중인 상태를 관리할 refreshing과 터치의 시작과 끝의 Y값을 관리할 startY를 설정했다.
reftch함수 불러오기
import { useGetAllPosts, useGetRecentPosts } from '@hooks/usePost';
const { refetch: allPostsRefetch } = useGetAllPosts();
const { refetch: recentPostsRefetch } = useGetRecentPosts();
PTR 시 전체 포스트(인기 포스트)와 최근포스트를 refetch해야 하기 때문에 다음 함수에서 사용할 reftech를 불러왔다.
터치 Start, Move, End 구분
useEffect(() => {
function handleTouchStart(event) {
...
}
function handleTouchMove(event) {
...
}
function handleTouchEnd() {
...
}
document.addEventListener('touchstart', handleTouchStart);
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, []);
터치의 Start, Move, End의 함수를 각각 작성하고 해당 이벤트리스너로 호출해준다.
그리고 반드시 클린업 함수를 사용해서 이벤트리스너를 제거해준다.
Start
function handleTouchStart(event) {
setStartY(event.touches[0].clientY);
}
Start는 startY state를 터치한 지점의 Y값으로 설정하는 것이 전부다.
Move
function handleTouchMove(event) {
const moveY = event.touches[0].clientY;
const pullDistance = moveY - startY;
if (pullDistance > 0) {
event.preventDefault();
if (pullDistance > 80) {
el.current.style.transform = 'translate(0, 40px)';
el.current.style.transition = '0.3s';
setRefreshing(true);
}
}
}
Move는 터치하고 있는 중의 Y지점을 moveY에 할당하고 startY와 비교해 총 터치한 길이인 pullDistance의 값을 구한다.
pullDistance가 80이 넘으면 el의 위치를 Y방향으로 40px옮기고 refreshing을 true로 설정한다.
여기서 el은 PTR을 사용하고자 하는 페이지의 컨테이너를 ref로 설정해 props로 받아 element로 사용하는 것이다.
const containerEl = useRef();
<Container ref={containerEl}>
<PullToRefresh el={containerEl} />
</Container>
End
function handleTouchEnd() {
if (refreshing) {
allPostsRefetch();
recentPostsRefetch();
setTimeout(() => {
setRefreshing(false);
el.current.style.transform = 'translate(0,0)';
}, 1000);
}
}
End에서는 터치가 종료된 상황에서 refreshing의 true/false 여부에 따라 포스트를 refetch하는 allPostsRefetch()와 recentPostsRefetch()를 실행시킨다.
그리고 1초 후에 refresh를 false로 변경하고 페이지의 컨테이너요소를 다시 원위치 시킨다.
렌더링
return (
<Container>
<Loader>{refreshing ? <CircularProgress color='inherit' /> : ''}</Loader>
</Container>
);
refreshing의 true/false의 여부(새로고침 중인지 아닌지)에 따라 CircularProgress 아이콘을 렌더링 시킨다.
따라서 스크린을 터치해서 아래로 당기면 아래와 같이 작동한다.
최종 코드
import React, { useEffect, useState } from 'react';
import { CircularProgress } from '@mui/material';
import { useGetAllPosts, useGetRecentPosts } from '@hooks/usePost';
import { Container, Loader } from './styles';
const PullToRefresh = ({ el }) => {
const { refetch: allPostsRefetch } = useGetAllPosts();
const { refetch: recentPostsRefetch } = useGetRecentPosts();
const [refreshing, setRefreshing] = useState(false);
const [startY, setStartY] = useState(0);
useEffect(() => {
function handleTouchStart(event) {
setStartY(event.touches[0].clientY);
}
function handleTouchMove(event) {
const moveY = event.touches[0].clientY;
const pullDistance = moveY - startY;
if (pullDistance > 0) {
event.preventDefault();
if (pullDistance > 80) {
el.current.style.transform = 'translate(0, 40px)';
el.current.style.transition = '0.3s';
setRefreshing(true);
}
}
}
function handleTouchEnd() {
if (refreshing) {
allPostsRefetch();
recentPostsRefetch();
setTimeout(() => {
setRefreshing(false);
el.current.style.transform = 'translate(0,0)';
}, 1000);
}
}
document.addEventListener('touchstart', handleTouchStart);
document.addEventListener('touchmove', handleTouchMove);
document.addEventListener('touchend', handleTouchEnd);
return () => {
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [refreshing, startY, allPostsRefetch, recentPostsRefetch, el]);
return (
<Container>
<Loader>{refreshing ? <CircularProgress color='inherit' /> : ''}</Loader>
</Container>
);
};
export default PullToRefresh;