[Android] PagerSnapHelper + LinearSmoothScroller

Ju-unn·2026년 8월 31일

프로젝트를 손보면서 홈 화면 상단 "지금 뜨고 있는 모임" 캐러셀을 3초마다 자동으로 넘어가게 만들었다. 새 라이브러리 없이 이미 쓰던 RecyclerView에 PagerSnapHelper 한 줄을 얹고 Handler로 돌렸는데, 그 과정에서 스냅과 스크롤이 싸우는 버그를 하나 밟았다. 그 버그를 LinearSmoothScroller로 잡은 이야기까지 정리한다.

상황

핫모임 캐러셀이 정적이었다. 인기 모임을 가로로 넘겨볼 수 있는데 사용자가 직접 스와이프해야만 다음 카드가 보였다. 첫 화면에서 "여기 활발하다"는 인상을 주려면 자동으로 넘어가는 움직임이 필요했다.

문제

이미 RecyclerView로 렌더링 중인 캐러셀을 3초마다 한 카드씩 자동 순환시킨다. 단,

  • 사용자가 손을 대면 멈추고, 떼면 재개.
  • 화면을 벗어나면 백그라운드에서 안 돌게 정리(누수 방지).
  • 새 의존성 0개.

개념부터

3-1. 개념부터: SnapHelper가 뭔가

💡 SnapHelper - RecyclerView의 스크롤이 멈출 때, 아무 위치에서나 서지 않고 항목 경계에 딱 맞춰(snap) 정지하게 해주는 도우미.

쉽게 말하면 - 서랍을 살짝 밀면 끝까지 딸깍 닫히는 것과 같다. 어중간한 위치에서 멈추지 않고 항상 "제자리"에 붙는다. 캐러셀도 카드가 반쯤 걸친 채 멈추지 않고, 한 카드가 화면에 딱 맞게 정렬된 상태로 선다.

그냥 RecyclerView는 스크롤하면 관성으로 아무 데서나 멈춘다. 카드가 반쯤 걸친 채로 서는 것이다. 캐러셀은 "한 카드가 딱 보이는" 상태로 멈춰야 하는데, 그걸 SnapHelper가 해준다. 종류가 둘 있다.

LinearSnapHelperPagerSnapHelper
스냅 위치화면 중앙에 가장 가까운 항목한 번에 한 페이지(뷰포트 단위)
느낌여러 개 흘러가다 중앙 정렬뷰페이저처럼 한 장씩
캐러셀에 적합갤러리/추천 리스트한 장씩 넘기는 캐러셀 ← 이번 케이스

핫모임은 "한 번에 한 카드"라 PagerSnapHelper가 맞다. (참고로 ViewPager2를 썼다면 스냅은 공짜지만, 아래 대안 비교에서 탈락시킨 이유가 있다.)

3-2. 어떻게 만들까 (대안 비교)

방식장점단점
ViewPager2 + 타이머페이지 개념 내장, 인디케이터 쉬움이미 RecyclerView로 만든 걸 갈아엎어야 함, 세로 스크롤 안에 가로 ViewPager2 넣으면 스크롤 충돌
캐러셀 라이브러리오토슬라이드·순환 공짜새 의존성, 기존 어댑터 재작성
RecyclerView + PagerSnapHelper + Handler (채택)쓰던 RecyclerView 그대로, 스냅만 얹음, 의존성 0오토슬라이드·터치정지·순환을 직접 배선

핫모임은 이미 RecyclerView로 렌더링 중이었다. ViewPager2로 갈아엎으면 어댑터를 다시 짜야 하고, 세로 스크롤 화면 안에 가로 ViewPager2를 넣는 스크롤 충돌까지 떠안는다. PagerSnapHelper는 기존 RecyclerView에 한 줄로 붙어 한 카드씩 스냅을 만들어주고, 자동 넘김은 Handler 루프면 충분했다.

3-3. 구현 (실코드)

① 스냅 부착 + 터치 정지/재개. PagerSnapHelper는 정말 한 줄이다.

// 한 카드씩 스냅 + 터치 시 오토슬라이드 정지/재개
new PagerSnapHelper().attachToRecyclerView(binding.rvHotMeetings);
binding.rvHotMeetings.setOnTouchListener((v, event) -> {
    switch (event.getActionMasked()) {
        case MotionEvent.ACTION_DOWN:
            stopAutoSlide();
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            startAutoSlide();
            break;
    }
    return false; // RecyclerView가 스크롤을 계속 처리하게 둠
});

return false가 중요하다. 터치 이벤트를 소비해버리면(true) RecyclerView가 스크롤을 못 한다. false로 흘려보내야 "오토슬라이드만 멈추고 사용자 스와이프는 정상 동작"이 된다.

② 3초 자동 순환 루프. Handler + postDelayed로 자기 자신을 다시 예약하는 러너블을 만든다.

private final Handler autoSlideHandler = new Handler(Looper.getMainLooper());
private static final long AUTO_SLIDE_INTERVAL = 3000L;

private final Runnable autoSlideRunnable = new Runnable() {
    @Override
    public void run() {
        if (binding != null) {
            int count = hotMeetingListAdapter.getItemCount();
            if (count > 1) {
                LinearLayoutManager lm = (LinearLayoutManager) binding.rvHotMeetings.getLayoutManager();
                int cur = lm.findFirstCompletelyVisibleItemPosition();
                if (cur == RecyclerView.NO_POSITION) cur = lm.findFirstVisibleItemPosition();
                int next = (cur + 1 >= count) ? 0 : cur + 1;   // 끝 → 처음 순환

                // ↓↓ 여기가 버그를 잡은 핵심 (아래 설명)
                LinearSmoothScroller scroller =
                        new LinearSmoothScroller(binding.rvHotMeetings.getContext()) {
                    @Override protected int getHorizontalSnapPreference() { return SNAP_TO_START; }
                    @Override protected float calculateSpeedPerPixel(android.util.DisplayMetrics dm) {
                        return super.calculateSpeedPerPixel(dm) * 2f; // 애니메이션 2배 느리게
                    }
                };
                scroller.setTargetPosition(next);
                lm.startSmoothScroll(scroller);
            }
            autoSlideHandler.postDelayed(this, AUTO_SLIDE_INTERVAL); // 자기 자신 재예약
        }
    }
};

count > 1 가드로 카드가 0~1개면 아무것도 안 한다(순환할 게 없으니 no-op, 크래시 방지). next 계산의 (cur + 1 >= count) ? 0 : cur + 1이 끝→처음 순환을 만든다.

③ 밟은 버그: peek 카드에서 제자리 회전. 처음엔 평범하게 smoothScrollToPosition(next)를 썼다. 그런데 옆 카드가 살짝 보이는 peek 레이아웃에서, 스크롤이 다음 카드로 가려는 순간 PagerSnapHelper가 "가장 가까운 스냅 위치"로 되밀어서 제자리에서 도는 현상이 났다. 스크롤과 스냅이 서로 목표가 달라 싸운 것이다.

해결은 스크롤 목표를 명시하는 것이다. LinearSmoothScroller의 getHorizontalSnapPreference()를 SNAP_TO_START로 오버라이드하면 "다음 카드를 시작 지점에 붙인다"가 목표로 고정돼, 스냅이 되밀 여지가 없어진다.

smoothScrollToPosition:  다음 카드로 →  ← 스냅이 되밈  = 제자리 회전 (버그)
LinearSmoothScroller(SNAP_TO_START):  다음 카드를 START에 고정 → 스냅과 목표 일치 (해결)

덤으로 calculateSpeedPerPixel을 super * 2f로 오버라이드해서 픽셀당 이동 시간을 2배로 늘렸다 - 자동 슬라이드 애니메이션이 2배 느려져 더 부드럽다. 인터벌(3초)은 그대로.

④ 생명주기 정리(누수 방지). 러너블은 화면을 참조하므로, 화면을 벗어나면 반드시 예약을 제거해야 한다. 중복 루프를 막기 위해 시작할 땐 항상 remove 후 post한다.

// 시작/정지 (중복 방지 위해 항상 remove 후 post)
private void startAutoSlide() {
    autoSlideHandler.removeCallbacks(autoSlideRunnable);
    autoSlideHandler.postDelayed(autoSlideRunnable, AUTO_SLIDE_INTERVAL);
}
private void stopAutoSlide() {
    autoSlideHandler.removeCallbacks(autoSlideRunnable);
}

onResume에서 startAutoSlide(), onPause/onDestroyView에서 stopAutoSlide()를 호출해 화면이 안 보일 땐 루프가 돌지 않게 한다.

결과

  • 핫모임 3초 간격 자동 순환, 끝→처음 wrap, 터치 시 정지/재개, 화면 이탈 시 정리.
  • peek 카드 제자리 회전 버그를 LinearSmoothScroller(SNAP_TO_START)로 제거.
  • 애니메이션 속도는 calculateSpeedPerPixel 오버라이드로 조절(인터벌은 유지).
  • 새 의존성 0개 - PagerSnapHelper/LinearSmoothScroller는 androidx.recyclerview에 이미 포함.

Before / After

  • Before: 정적 캐러셀. 사용자가 직접 스와이프해야만 다음 카드가 보임.
  • After: 3초 자동 순환(터치는 존중), 스냅 안정적으로 한 카드씩 이동.

Q&A

Q. PagerSnapHelper와 LinearSnapHelper는 언제 뭘 쓰나요?
PagerSnapHelper는 뷰페이저처럼 "한 번에 한 페이지"를 스냅합니다. LinearSnapHelper는 화면 중앙에 가장 가까운 항목을 스냅합니다. 한 장씩 넘기는 캐러셀은 PagerSnapHelper, 여러 개가 흐르다 중앙 정렬되는 갤러리형 리스트는 LinearSnapHelper가 어울립니다.

Q. 그냥 ViewPager2를 쓰면 스냅이 공짜인데 왜 안 썼나요?
핫모임이 이미 RecyclerView로 렌더링되고 있었습니다. ViewPager2로 바꾸면 어댑터를 다시 짜야 하고, 세로 스크롤 화면 안에 가로 ViewPager2를 넣으면 스크롤 방향 충돌을 별도로 처리해야 합니다. 기존 구조에 PagerSnapHelper 한 줄을 얹는 편이 훨씬 적은 변경으로 같은 결과를 냅니다.

Q. peek 카드에서 제자리 회전 버그는 정확히 왜 났나요?
기본 smoothScrollToPosition은 "다음 카드로 스크롤"이 목표인데, 옆 카드가 살짝 보이는 peek 레이아웃에서는 PagerSnapHelper가 "가장 가까운 스냅 위치"로 되밀려 합니다. 스크롤 목표와 스냅 목표가 어긋나 서로 당기면서 제자리에서 도는 현상이 생깁니다. LinearSmoothScroller에서 SNAP_TO_START로 목표를 "다음 카드를 시작 지점에 붙임"으로 못 박으면 두 목표가 일치해 해결됩니다.

Q. Handler 루프가 백그라운드에서도 계속 도나요?
아니요. onResume에서 시작하고 onPause/onDestroyView에서 removeCallbacks로 멈춥니다. 화면이 안 보이면 루프가 돌지 않습니다. 또 시작할 때 항상 remove 후 post를 해서 루프가 중복으로 두 개 돌지 않도록 합니다.

Q. 터치 리스너에서 return false를 하는 이유는?
true를 반환하면 터치 이벤트를 소비해버려 RecyclerView가 스크롤을 못 합니다. false로 흘려보내야 "오토슬라이드만 멈추고, 사용자의 스와이프 스크롤은 정상 동작"하게 됩니다.

Q. 애니메이션 속도는 어떻게 조절했나요?
LinearSmoothScroller의 calculateSpeedPerPixel을 오버라이드해 기본값의 2배를 반환하도록 했습니다. 픽셀당 이동 시간이 2배가 되어 슬라이드가 더 느리고 부드러워집니다. 넘어가는 간격(3초)은 그대로 두고 애니메이션 속도만 바꾼 것입니다.

profile
한줄한줄 기록해보자

0개의 댓글