노래: ‘당기시오’라고 써 있지만 미는 사람들

💆‍♂️ 당겨서 새로고침이란?

말 그대로 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

// defaults.js 
 ...
 resistanceFunction: t => Math.min(1, t / 2.5),
 ...

events.js

...
_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 컴포넌트로 구현되어 있다. 그것들 중 빠진 것들을 몇가지 예시로 들어보면 이렇다.

  • Spinner 컴포넌트로 고정되어 있지 않고 상위에서 해당 영역을 커스텀할 수 있도록 Prop을 통해 받아옴
    • 애니메이션을 커스텀하게 적용할 수 있도록 구현
  • 애니메이션이 종료되면서 인디케이터 영역이 축소됨
  • 터치 이벤트에 따른 다양한 콜백들을 상위 컴포넌트로 전달

여기서 첫번째만 적용하려고 바꾸어도 기존 코드에서 생각해볼 것들이 많아지게 된다. 이러한 부분들은 여기 코드에 녹이지 않았는데, 코드가 정말 방대해지고 복잡도가 상승하기 때문에 직접 구현해보면서 느껴보는게 더 좋을 것 같아서 추가하지 않았다.

이외에도 공통화를 위해 생각하면서 개발하다 보면 생각보다 더 어려운 점이 생기게 될 것이다. 게다가 내가 개발한 환경이 웹뷰로 제공해야 했기에 실제 배포된 곳에서는 더 많은 이슈를 대응해야 했다.

본인이 PTR을 개발해봤거나 개발을 해야하는 상황에 놓였다면 어느정도 도움이 되는 글이 되었으면 한다.

참고

profile
하다보면 안되는 것이 없다고 생각하는 3년차 프론트엔드 개발자입니다.

0개의 댓글