[TIL] View Pager Circular Scroll, Infinity Scroll

박봉팔·2024년 2월 7일
0

View Pager

이전에는 View PagerFragmentStateAdapter를 사용해 단순하게 Fragment만 교체하는 방식을 사용했었다.

하지만 Fragment전체를 교체하는게 아니라 일부분에 아이템들을 스크롤하는 섹션을 만들고 싶었기에, RecyclerViewListAdapter를 사용해 구현했다.
(RecyclerView를 사용해도 되지만 ViewPager특유의 찰지게 스크롤 되는 느낌을 살리고싶어서 ViewPager를 사용했다.)


View Pager Scroll

ViewPager는 기본적으로 RecyclerView를 기반으로 하기때문에 전체 Item의 갯수가 정해져있다.

따라서 현재 노출되고 있는 아이템이 기본적으로 ItemList의 마지막 아이템일 경우 더이상의 스크롤이 불가능하다.
(마찬가지로 제일 처음일 경우에도 이전으로 스크롤이 불가능하다.)

하지만 많은 앱들이 View Pager형식으로 끝없이 스크롤 되는 기능을 사용하고 있다. 그래서 기왕 ViewPager를 사용하는 김에 무한 스크롤에 도전해 보기로했다.


무한? 순환?

View Pager의 스크롤에 관해 찾아보다보니 크게 2가지의 방법이 있었다
무한(Infinity)으로 스크롤 되는 방식과 (사실 무한에 가까워보이는) 계속해서 순환하는(Circular) 형식 으로 구현되는 방식이었다.


Infinity Scroll

구글에 검색해보면 보통 무한정 스크롤되는 것을 Infinity스크롤이라고 지칭한다.

가장 많이 나오는 방법중 하나로 대부분의 Infinity방식은 View Pager에 연결된 어댑터의
Item Count를 큰 값으로 만들어 아이템의 position이 무한정 증가되는 것처럼 만드는 방법이다.
(많은 글들이 Item countInt.MAX_VALUE를 사용해 Int타입의 최댓값을 사용하고 있었다.)

override fun getItemCount(): Int = Int.MAX_VALUE

override fun onBindViewHolder(holder: TopContentViewHolder, position: Int) {
        val item = getItem(position % currentList.size)
		...
}

getItemCount에 어탭터에 전달된 리스트의 크기보다 큰 값을 지정해 어댑터의 position값이 계속해서 증가 하도록 만들고, 사용되는 아이템의 포지션을 어댑터에 크기로 나눈나머지로 지정해 position이 증가되도 계속해서 아이템들이 반복되어 나오도록 구현한다.

이 방법의 경우 View Pager에 노출되는 아이템의 포지션이 처음 혹은 마지막일 경우 스크롤을 할 수 없다는 단점이 있다.
(물론 최초에 current Item의 값을 중간으로 지정해주면 스크롤되는 것처럼 구현할 수 있다.)


Circular Scroll

Infinity스크롤에 대해 검색하다가 마지막 포지션의 아이템에서 스크롤 할 경우 첫 포지션으로 이동하게만드는 방법을 찾을 수 있었고, 이를 많은 글들에서 순환스크롤 Circular 스크롤이라고 지칭하는것을 알게됐다.

Infinity방법과의 가장 큰 차이점은 Infinity방법이 View Pager에 연결된 어댑터 내부에서 아이템 크기를 조정해 무한정 스크롤 되는 것처럼 구현했다면, Circular방법은 View PagerOnPageChangeCallback을 연결해 스크롤과 포지션을 받아서 가장 마지막 포지션에서 스크롤 할 경우 실제로 제일 처음 포지션의 아이템으로 이동시켜 무한정 스크롤 할 수 있도록 구현했다는 점이다.

with(binding.viewPager) {
	adapter = topContentsAdapter
	registerOnPageChangeCallback(getPageChangeCallback())
}

private fun getPageChangeCallback(): OnPageChangeCallback {
	return object : OnPageChangeCallback() {
		var currentItems = listOf<VideoItem>()
		var currentState = -1

        override fun onPageScrolled(
        	position: Int,
            positionOffset: Float,
            positionOffsetPixels: Int
		) {
        		super.onPageScrolled(position, positionOffset, positionOffsetPixels)
                
                if (position == currentItems.size - 1 && positionOffset <= 0 && currentState == 1) {
                    binding.viewPager.setCurrentItem(0, false)
                    return
                }

                if (position == 0 && positionOffset <= 0 && currentState == 1) {
                    binding.viewPager.setCurrentItem(currentItems.size - 1, false)
                    return
                }
    	}
        
        override fun onPageScrollStateChanged(state: Int) {
                super.onPageScrollStateChanged(state)
                currentState = state
		}
	}
}

OnPageChangeCallback 메서드를 Override해게되면 onPageScrolled메서드를 사용할 수 있게 되는데, 전달받는 매개변수를 각각 살펴보면 다음과 같다.

  • positionOffsetPixels : 스크롤 했을때 아이템이 이동하는 픽셀수를 나타낸다.

  • positionOffset : 스크롤시 아이템의 위치를 나타낸다.
    • 최초 아이템의 Offset은 0이며 다음 아이템으로 스크롤시 1까지 소수점 단위로 값이 증가한다.
      반대로 이전 아이템으로 스크롤 할 경우 1에서부터 소수점 단위로 값이 줄어든다.

  • position : positionOffset이 0과 1사이인 아이템의 포지션을 나타낸다.
    • 예를들어 다음 아이템으로 스크롤시 positionOffset이 1이 되어 완전히 아이템이 전환되었을 경우 다음 아이템의 포지션이 된다.
    • 반대로 이전 아이템으로 스크롤 할 경우에는 이전아이템의 positionOffset이 1부터 작아지기때문에 즉시 이전 아이템의 포지션이 된다.

또한 onPageScrollStateChanged메서드를 사용하면 현재 스크롤을 하고있는지 알 수 있는데 (스크롤시 state가 1이 된다.) 이를통해 처음/마지막 아이템에서 스크롤을 하면 원하는 코드가 작동되도록 구현이 가능하다.

따라서 onPageScrolled메서드를 사용해 포지션 값이 처음/마지막 일때 이전/다음으로 스크롤 할 시
(currentState로 드래그 중인가 확인, positionOffset으로 다음/이전 으로 스크롤 중인지 확인 - positionOffset0보다 크면 다음 아이템으로 이동중)
setCurrentPositon메서드로 마지막/처음으로 이동시켜 순환하는것을 구현하는게 가능하다.


하지만 이 방식의 경우 처음이나 마지막에서 스크롤을 시도할 경우 다른때와 다르게 아이템이 자연스럽게 스크롤 되는게 아니라, 갑자기 바뀌다보니 어색한 느낌을 준다.


원하는대로 혼용해보자

Infinity방식의 자연스러운 아이템 전환과 Circular방식의 순환방식을 함께 사용하기 위해 두 로직을 섞어서 구성해보았다.

Adapter

override fun getItemCount(): Int = currentList.size * 3

override fun onBindViewHolder(holder: TopContentViewHolder, position: Int) {
        val item = getItem(position % currentList.size)

		...
    }

우선 어댑터에서 아이템 사이즈를 현재 리스트 * 3으로 지정해 아이템을 3덩이로 나눠준다.

이렇게 되면 가운데에 있는 아이템은 처음/마지막에서 이동할 경우 각각 리스트의 마지막/처음 아이템으로 이동하는것과 같이 보이게 된다.

이제 ScrollCallback을 사용해 가운데에 있는 아이템 덩이의 처음/마지막 값이 이동해 이전/다음 아이템으로 이동하게 되면 setCurrentPosition으로 포지션을 이동하게 해주면 된다.

private fun getPageChangeCallback(): OnPageChangeCallback {
	return object : OnPageChangeCallback() {
    	var currentItems = listOf<VideoItem>()

        override fun onPageScrolled(
        	position: Int,
            positionOffset: Float,
            positionOffsetPixels: Int
		) {
				super.onPageScrolled(position, positionOffset, positionOffsetPixels)
                
                // item size -1인 포지션은 첫번째 덩이의 마지막 아이템이다.
                // 따라서 첫번째 덩이의 마지막 아이템으로 포지션이 옮겨지면 2번째 덩이의 마지막으로 이동한다.
                if (position == currentItems.size - 1 && positionOffset <= 0.1f) {
                    binding.vpTopContentImage.setCurrentItem((currentItems.size * 2) - 1, false)
                    return
                }
				// item size * 2인 포지션은 세번째 덩이의 첫번째 아이템이다.
                // 따라서 3번째 덩이의 첫번째 아이템으로 포지션이 옮겨가면 2번째 덩이의 첫번째로 이동한다.
                if (position == currentItems.size * 2 && positionOffset <= 0.1f) {
                    binding.vpTopContentImage.setCurrentItem(currentItems.size, false)
                    return
                }
    	}
	}
}

positionOffset0f일경우 스크롤이 민감하기때문에 아직 진행되고 있는 상황에 한번더 이전으로 스크롤을 입력하면 원하는 대로 포지션이 바뀌지 않는 경우가 생겼다.

그래서 여러가지로 시도해본결과 0.1f정도면 적당히 이전으로 스크롤 되지 않았다.


오늘은 어땠나요?

안녕하세요~^^

profile
개발 첫걸음! 가보자구!

0개의 댓글