당겨서 새로고침이란(Pull to Refresh) 모바일에서 흔히 사용되는 사용자 인터페이스 패턴 중 한개이다.
사용자가 화면을 아래로 끌어 당기면 데이터가 새로고침되거나 업데이트된다.
뉴스피드, 소셜미디어, 이메일 등에서 많이 쓰인다고 하는데 사실 요즘 사용하는 앱 대부분에 구현 되어 있다.

직관적이고, 새로고침 중이라는 로딩 스피너가 피드백을 제공하고, 서버로부터 최신 데이터를 가져와 화면에 업데이트 할 수 있다는 특징이 있다.
react-pull-to-refresh, react-simple-pull-to-refresh
라이브러리가 있어서 라이브러리를 사용해볼까 했는데
생각보다 구현하기가 까다롭지 않을 것 같았고, 스피너도 원하는 디자인으로 적용하고싶어 직접 구현해보기로 했다.
내가 구현 방법은 아래와 같다.
1. 컴포넌트가 렌더링되면 인디케이터 영역의 높이는 '0'으로 설정해 보이지 않도록 함.
2. 사용자가 화면을 당기는 동작에 따라 인디케이터 영역의 높이를 동적으로 조절
3. 최대로 당긴 거리에 도달하면 새로고침 작업이 트리거 됨
4. 새로고침이 완료되면 인디케이터 영역의 높이를 '0'으로 초기화
1. 초기 상태 설정
const spinnerRef = useRef<HTMLDivElement>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [startY, setStartY] = useState(0);
const [isTouch, setIsTouch] = useState(false);
const [pulled, setPulled] = useState(false);
spinnerRef: 로딩 인디케이터의 DOM 요소 참조를 저장
isRefreshing: 새로고침 상태를 관리
startY: 터치 시작 위치를 저장
isTouch: 터치 이벤트인지 여부를 저장
pulled: 화면이 당겨졌는지 여부를 저장
2. 초기화 함수
const resetToInitial = () => {
if (spinnerRef.current) {
spinnerRef.current.style.height = '0';
spinnerRef.current.style.willChange = 'unset';
}
setPulled(false);
setIsRefreshing(false);
};
인디케이터의 높이를 0으로 설정하여 초기화
새로고침 상태와 당김 상태를 초기화
willChange 속성을 초기화하여 해당 요소에 특정 속성이 변경될 예정이 없음을 브라우저에게 알려
불필요한 메모리 사용을 줄이고 성능을 최적화!
3. 당김 시작
const onStart = (y: number, touch: boolean) => {
setStartY(y);
setIsTouch(touch);
setPulled(true);
if (spinnerRef.current) {
spinnerRef.current.style.willChange = 'height';
}
};
터치 시작 위치를 저장하고, 당김 상태를 활성화
인디케이터 영역의 높이 변경을 미리 준비
spinnerRef.current요소의 height속성이 변경될 예정임을 브라우저에게 알림
4. 당김 중
const onMove = (y: number) => {
if (pulled && spinnerRef.current) {
const moveY = y;
const pulledDistance = Math.min(Math.pow(moveY - startY, 0.875), maxDistance);
if (pulledDistance > 0) {
spinnerRef.current.style.height = `${pulledDistance}px`;
preventBodyScroll();
if (pulledDistance >= maxDistance) {
setIsRefreshing(true);
} else {
setIsRefreshing(false);
}
} else {
ableBodyScroll();
resetToInitial();
}
}
};
사용자가 당긴 거리만큼 인디케이터 영역의 높이를 조절
최대 거리를 초과하면 새로고침 상태로 전환
pulledDistance의 계산식은 당김의 민감도를 조절하여 사용자 경험을 향상시키기 위함
moveY - startY: 사용자가 화면을 당긴 거리 (터치 시작 위치 - 현재 터치 위치)
Math.pow(moveY - startY, 0.875): 당긴 거리를 0.875 지수로 조절합니다. 이는 당김 거리를 비선형적으로 변환하여, 당김의 민감도를 조절합니다. 지수가 1보다 작으면 당김 초기에는 덜 민감하고, 더 많이 당길수록 민감도가 높아지는 효과
예를 들어, 사용자가 화면을 100픽셀 당겼을 때, Math.pow(100, 0.875)는 약 56.2 픽셀이 됩니다. 이렇게 하면 당김 초기의 민감도가 줄어들어 자연스럽고 부드러운 사용자 경험을 제공
const handleEnd = () => {
if (isTouch && pulled) {
onEnd();
}
};
const onEnd = async () => {
if (pulled) {
ableBodyScroll();
if (isRefreshing) {
try {
await onRefresh();
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
resetToInitial();
} catch (error) {
console.error('Error while refreshing:', error);
}
} else {
resetToInitial();
}
}
};
터치가 종료되면 새로고침 작업을 수행하고, 완료되면 초기 상태로 복귀
onRefresh함수를 호출하여 새로고침 작업을 수행. 비동기로 동작하며 작업이 완료될때까지 기다림
setTimeOut을 걸어 새로고침 작업 후 500밀리초간 대기 => 사용자가 새로고침 완료를 인식할 수 있도록 지연을 추가했다.
애니메이션은 로티를 사용했다
import React, { ReactNode, useEffect, useRef, useState } from 'react';
interface PullToRefreshProps {
children: ReactNode;
onRefresh: () => void;
maxDistance: number;
loadingComponent: ReactNode;
}
const PullToRefresh = ({ children, onRefresh, maxDistance, loadingComponent }: PullToRefreshProps) => {
const spinnerRef = useRef<HTMLDivElement>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const [startY, setStartY] = useState(0);
const [isTouch, setIsTouch] = useState(false);
const [pulled, setPulled] = useState(false);
useEffect(() => {
const touchMoveListener = (e: TouchEvent) => {
if (isTouch && pulled) {
onMove(e.touches[0].clientY);
e.preventDefault();
}
};
document.addEventListener('touchmove', touchMoveListener, { passive: false });
return () => {
document.removeEventListener('touchmove', touchMoveListener);
};
}, [isTouch, pulled]);
const resetToInitial = () => {
if (spinnerRef.current) {
spinnerRef.current.style.height = '0';
spinnerRef.current.style.willChange = 'unset';
}
setPulled(false);
setIsRefreshing(false);
};
const onStart = (y: number, touch: boolean) => {
setStartY(y);
setIsTouch(touch);
setPulled(true);
if (spinnerRef.current) {
spinnerRef.current.style.willChange = 'height';
}
};
const onMove = (y: number) => {
if (pulled && spinnerRef.current) {
const moveY = y;
const pulledDistance = Math.min(Math.pow(moveY - startY, 0.875), maxDistance);
if (pulledDistance > 0) {
spinnerRef.current.style.height = `${pulledDistance}px`;
preventBodyScroll();
if (pulledDistance >= maxDistance) {
setIsRefreshing(true);
} else {
setIsRefreshing(false);
}
} else {
ableBodyScroll();
resetToInitial();
}
}
};
const handleEnd = () => {
if (isTouch && pulled) {
onEnd();
}
};
const onEnd = async () => {
if (pulled) {
ableBodyScroll();
if (isRefreshing) {
try {
await onRefresh();
await new Promise((resolve) => {
setTimeout(resolve, 500); // 최대 1초까지 기다림
});
resetToInitial();
} catch (error) {
console.error('Error while refreshing:', error);
}
} else {
resetToInitial();
}
}
};
const ableBodyScroll = () => {
document.body.style.overflow = 'auto';
};
const preventBodyScroll = () => {
document.body.style.overflow = 'hidden';
};
const handleTouchStart = (e: React.TouchEvent) => {
if (window.scrollY === 0) {
onStart(e.touches[0].clientY, true);
}
};
const handleMouseDown = (e: React.MouseEvent) => {
if (window.scrollY === 0) {
onStart(e.clientY, false);
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (!isTouch && pulled) {
onMove(e.clientY);
e.preventDefault();
}
};
const handleMouseUp = () => {
if (!isTouch) {
onEnd();
}
};
return (
<div>
<div ref={spinnerRef}>{isRefreshing && loadingComponent}</div>
<div
onTouchStart={handleTouchStart}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onTouchEnd={handleEnd}
style={{ cursor: 'pointer' }}
>
{children}
</div>
</div>
);
};
export default PullToRefresh;
https://velog.io/@haryan248/%EB%8B%B9%EA%B2%A8%EC%84%9C-%EC%83%88%EB%A1%9C%EA%B3%A0%EC%B9%A8
https://velog.io/@sjoleee_/React-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-Lottie
https://kyledev.tistory.com/144
이미지 - 핀터레스트