말 그대로 UI의 특정 영역을 아래로 당겨서 새로고침하는 방식이다. 영어로는 Pull To Refresh
라고 부른다.
당겨서 새로고침이 적용되어 있는 곳들은 꽤나 많고, 대표적으로 배달의 민족, 토스, 당근 등 앱에서 자주 사용되는 방식이다. 각각의 서비스마다 세부적인 동작방식은 약간씩 다르지만 전체적인 틀에서는 동일한 구조를 지니고 있다. (각 앱들은 웹으로 구현했는지 네이티브로 구현했는지 정확히는 모르겠다..😅)
토스 | 당근 | 배달의 민족 |
---|---|---|
React로 당겨서 새로고침(Pull To Refresh)를 구현하기 전에 먼저 그림을 통해 대략적인 방식을 먼저 생각해보자
PTR을 구현할 수 있는 방식은 구현하는 사람의 방식에 따라 정말 여러 가지로 나뉠 수 있는데, 내가 생각했던 몇 가지를 그려보았다.
방법 1.
리스트 영역 뒤쪽에 인디케이터를 배치하여 사용자가 끌어내렸을 때 나타나는 형태로 구현하는 방식이다.
방법 2.
인디케이터 영역의 높이를 0으로 초기 세팅을 해놓고, 사용자가 끌어내린 만큼 높이를 확보하는 방식이다.
방법 3.
마지막으로 인디케이터 영역을 거꾸로 위로 배치시켜 놓은 다음, 끌어내린 만큼 인디케이터, 컨텐츠 두 영역 모두를 Y만큼 내리는 구현 방식이다.
물론 이 방식 외에 여러가지 아이디어가 있습니다.
제가 생각한 방안이 정답은 아닙니다.
내가 구현한 방식은 2번째 방법으로 했는데, 이를 선택한 이유가 몇가지 있다.
먼저, 방법 1로 하게 될 경우 translateY
로 이동하기에 성능도 챙길 수 있지만, 하단에 배치되어 있는 UI 영역을 같이 내릴 수가 없었기에 컴포넌트 구조를 바꿔야 한다는 문제가 있었다.
그림과 코드로 표현을 하자면 다음과 같다.
// ex) 컴포넌트 구조
<PullToRefresh>
<List />
</PullToRefresh>
<Footer />
PullToRefresh
컴포넌트를 하위 영역(Footer) 까지 감싸지 않으면 그림처럼 가려지는 문제가 발생하게 된다.
물론 overflow:hidden
으로 숨길 수는 있겠지만 내가 구현하고 싶었던 것은 초록색 영역도 하위로 같이 내리고 싶었다.
// ex) 컴포넌트 구조
<PullToRefresh>
<List />
<Footer />
</PullToRefresh>
따라서Footer
까지 같이 내려가도록 하기 위해서는 같이 감싸야하는 형태가 필연적인 구조가 되게 된다. 그래서 이러한 구조의 의존성을 제거하고자 약간의 성능을 포기하고 방법2를 선택하게 되었다.
이제 어떻게 구현할지 대략적인 그림이 그려졌으니 코드를 통해 구현해보도록 하자.
// PullToRefresh 컴포넌트
const PullToRefresh = ({ children, onRefresh, maxDistance }: Props) => {
const spinnerRef = useRef(null);
// 새로고침 확인 상태
const [isRefreshing, setIsRefreshing] = useState(false);
// 터치를 시작한 Y 지점
const [startY, setStartY] = useState(0);
const resetToInitial = () => {
spinnerRef.current.style.height = '0';
}
const onTouchStart = (e: TouchEvent) => {
// 터치를 시작할 때 Y지점을 저장
setStartY(e.touches[0].clientY);
}
const onTouchMove = (e: TouchEvent) => {
// 터치를 통해 움직인 Y지점
const moveY = e.touches[0].clientY;
// 사용자가 당긴 Y거리
const pulledDistance = moveY - startY;
spinnerRef.current.style.height = `${pulledDistance}px`
if (pulledDistance >= maxDistance) {
// 새로고침이 필요하다고 체크
setIsRefreshing(true);
} else {
setIsRefreshing(false);
}
}
const onTouchEnd = () => {
// 새로고침이 필요한 상태라면,
if (isRefreshing) {
onRefresh();
} else {
// 초기 상태로 원상복구
resetToInitial()
}
}
return (
<div>
<div ref="spinnerRef">
<Spinner />
<div>
<div
onTouchStart={(e) => onTouchStart(e)}
onTouchMove={(e) => onTouchMove(e)}
onTouchEnd={(e) => onTouchEnd(e)}
>
{children}
</div>
</div>
)
}
css나 디테일적인 요소는 빼고 대략적인 코드를 작성해보았다.
라이브러리나 직접 구현한 다른 블로그들도 대부분 비슷한 방식을 통해 구현이 이뤄지고 있다.
그러나 이렇게 직접 구현해보면 알겠지만, 개선해야 할 점이 몇가지 있는데, 바로 스크롤 이벤트과 관련된 것이다. (내가 찾지 못하거나 잘못사용 했을 수 있겠지만, 대부분의 라이브러리들이 이를 해결하지 못한 채 구현되어 있었다.)
즉, 터치가 시작될 때 스크롤을 막지 않으면 사용자가 잡고 놓지 않은 상태로 위아래로 이동할 때 인디케이터 영역이 닫히지 않은 상태로 움직이게 된다. 이를 개선해보자.
const PullToRefresh = ({ children, onRefresh, maxDistance }: Props) => {
...
const onTouchMove = (e: TouchEvent) => {
...
// 사용자가 당긴 Y거리
const pulledDistance = moveY - startY;
if (pulledDistance <= 0) {
// 당긴 거리가 0이하면 스크롤 활성화
ableBodyScroll();
resetToInitial();
}
if (pulledDistance > 0) {
// 당긴거리가 존재하면 스크롤 막기
preventBodyScroll();
}
...
}
const onTouchEnd = () => {
// 터치가 끝나면 스크롤을 다시 활성화
ableBodyScroll();
...
}
// ...
}
여기서 당긴 거리가 0이라는 것은 당긴 거리 만큼 인디케이터에 height을 주기 때문에 인디케이터 영역이 모두 닫혔다는 것과 동일하다. 따라서 인디케이터가 모두 닫히면 다시 스크롤을 활성화 시켜서 정상적으로 스크롤을 동작시키는 것이다.
이제 마지막으로 성능적인 부분을 개선하고, 잡고 당기는 느낌으로 주기 위한 작업을 해보자.
애니메이션을 개선하기 위해서는 css의 will-change
라는 속성을 사용할 수 있다.
will-change
속성 추가
const PullToRefresh = ({ children, onRefresh, maxDistance }: Props) => {
...
const resetToInitial = () => {
spinnerRef.current.style.height = '0';
// willChange 속성은 제거해준다.
spinnerRef.current.style.willChange = 'unset';
}
const onTouchStart = (e: TouchEvent) => {
...
// 터치가 시작되면 willChange 속성을 추가해준다.
spinnerRef.current.style.willChange = 'height';
}
// ...
}
당기는 듯한 느낌 추가하기
당기는 듯한 느낌을 추가하기 위해서는 당긴 거리만큼 즉각적으로 바로 높이를 주게 되면 안된다.
라이브러리들을 살펴보면 라이브러리마다 그 방식은 다르게 적용되어 있다. 예를 들어 pullToRefresh.js 라이브러리의 코드를 가져왔다.
// defaults.js
...
resistanceFunction: t => Math.min(1, t / 2.5),
...
...
_shared.distResisted = _el.resistanceFunction(_shared.distExtra / _el.distThreshold)
* Math.min(_el.distMax, _shared.distExtra);
...
코드가 어려워 보이지만, 바로 높이를 적용하는 것이 아닌 저항(2.5)
을 주어서 적용하는 방식을 선택했다.
나는 이것보다 조금 더 직관적이고 쉬운 방식을 택하였다.
const onTouchMove = (e: TouchEvent) => {
...
// 저항값을 포함한 당긴 거리
const pulledDistance = Math.min(Math.pow(moveY - startY, 0.875), maxPulledDistance);
...
}
최대로 커질 수 있는 거리와 Math.pow
를 통해 당기는 듯한 계산식을 적용하였다.
// PullToRefresh 컴포넌트
const PullToRefresh = ({ children, onRefresh, maxDistance }: Props) => {
const spinnerRef = useRef(null);
// 새로고침 확인 상태
const [isRefreshing, setIsRefreshing] = useState(false);
// 터치를 시작한 Y 지점
const [startY, setStartY] = useState(0);
const resetToInitial = () => {
spinnerRef.current.style.height = '0';
// willChange 속성은 제거해준다.
spinnerRef.current.style.willChange = 'unset';
}
const onTouchStart = (e: TouchEvent) => {
// 터치를 시작할 때 Y지점을 저장
setStartY(e.touches[0].clientY);
// 터치가 시작되면 willChange 속성을 추가해준다.
spinnerRef.current.style.willChange = 'height';
}
const onTouchMove = (e: TouchEvent) => {
// 터치를 통해 움직인 Y지점
const moveY = e.touches[0].clientY;
// 사용자가 당긴 Y거리
// 저항값(0.875)을 포함한 당긴 Y 거리
const pulledDistance = Math.min(Math.pow(moveY - startY, 0.875), maxPulledDistance);
spinnerRef.current.style.height = `${pulledDistance}px`
if (pulledDistance <= 0) {
// 당긴 거리가 0이하면 스크롤 활성화
ableBodyScroll();
resetToInitial();
}
if (pulledDistance > 0) {
// 당긴 거리가 존재하면 스크롤 막기
preventBodyScroll();
}
if (pulledDistance >= maxDistance) {
// 새로고침이 필요하다고 체크
setIsRefreshing(true);
} else {
setIsRefreshing(false);
}
}
const onTouchEnd = () => {
// 터치가 끝나면 스크롤을 다시 활성화
ableBodyScroll();
// 새로고침이 필요한 상태라면,
if (isRefreshing) {
onRefresh();
} else {
// 초기 상태로 원상복구
resetToInitial();
}
}
return (
<div>
<div ref="spinnerRef">
<Spinner />
<div>
<div
onTouchStart={(e) => onTouchStart(e)}
onTouchMove={(e) => onTouchMove(e)}
onTouchEnd={(e) => onTouchEnd(e)}
>
{children}
</div>
</div>
)
}
실제로 내가 직접 구현한 코드는 이것보다 훨씬 복잡하고 공통화를 위해 힘쓴 UI 컴포넌트로 구현되어 있다. 그것들 중 빠진 것들을 몇가지 예시로 들어보면 이렇다.
여기서 첫번째만 적용하려고 바꾸어도 기존 코드에서 생각해볼 것들이 많아지게 된다. 이러한 부분들은 여기 코드에 녹이지 않았는데, 코드가 정말 방대해지고 복잡도가 상승하기 때문에 직접 구현해보면서 느껴보는게 더 좋을 것 같아서 추가하지 않았다.
이외에도 공통화를 위해 생각하면서 개발하다 보면 생각보다 더 어려운 점이 생기게 될 것이다. 게다가 내가 개발한 환경이 웹뷰로 제공해야 했기에 실제 배포된 곳에서는 더 많은 이슈를 대응해야 했다.
본인이 PTR을 개발해봤거나 개발을 해야하는 상황에 놓였다면 어느정도 도움이 되는 글이 되었으면 한다.
2024.07.12 추가
운영 배포되어 모바일에서 확인이 가능해졌습니다!
적용된 화면
참고
- PTR(Pull To Refresh) 구현: https://kyledev.tistory.com/144
- BoxFactura/pulltorefresh 라이브러리: https://github.com/BoxFactura/pulltorefresh.js/
- react-simple-pull-to-refresh: https://www.npmjs.com/package/react-simple-pull-to-refresh
글 잘 읽었습니다! 구현 방법 설명하는 부분에서 그림으로 설명 해주셨는데 혹시 그리실때 사용하는 템플릿 또는 앱이 있으실까요? 있다면 공유해주시면 감사하겠습니다!!