compose에서 ViewPager2를 사용하면서 겪은 어려움을 공유하고자 한다. 다른 사람들은 이 글을 보고 조금더 순조롭게 ViewPager를 사용하길 바란다.

ViewPager 라이브러리는 2개..?

수평으로 스크롤 가능한 HorizontalPager() 컴포저블을 사용하려고 했다. 아래의 사진을 봤을 때 자동완성으로 가능한 HorizontalPager()이 2개가 된다. 여기서 약간 의아하다고 생각할 것이다.


2개의 HorizontalPager() 컴포저블 함수는 라이브러리가 다르다. 당연히 각 라이브러리와 호환이 되는 Indicator를 적용시켜야 된다.

  • androidx.compose.foundation.pager
 // viewPager2
implementation ("androidx.compose.foundation:foundation:1.4.0")
implementation ("com.tbuonomo:dotsindicator:5.0")
  • com.google.accompanist.pager
// viewPager2
implementation ("com.google.accompanist:accompanist-pager:0.20.1")
implementation ("com.google.accompanist:accompanist-pager-indicators:0.20.1")

나는 처음에 com.google.accompanist.pager를 이용해서 ViewPager를 구현했다. 그러나 enableScroll 기능이 없었고 스크롤 설정(사용자가 뷰를 직접 스크롤 가능한지, 불가능한지)을 하기 위해 androidx.compose.foundation.pager를 사용하게 되었다.

데모 영상

이번 포스팅은 androidx.compose.foundation.pager의 라이브러리를 사용해서 업로드했다. 수평ViewPager 스크롤 시 수직ViewPager 자동으로 스크롤 되게 만들었다. 추가적으로 viewPager에서 무한 스크롤이 가능하게 수정했다.


변수 초기화

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun OnBoardViewPager(modifier: Modifier = Modifier) {
    val pageCount = Int.MAX_VALUE

    val viewPagerItems =
        listOf(ViewPagerItem.Item1, ViewPagerItem.Item2, ViewPagerItem.Item3, ViewPagerItem.Item4)

    val horizontalState = rememberPagerState { pageCount }
    val verticalState = rememberPagerState { pageCount }
    val contentPadding = PaddingValues(top = 50.dp, start = 70.dp, end = 70.dp)
    val indicatorType = SpringIndicatorType(
        dotsGraphic = DotGraphic(
            13.dp,
            borderWidth = 2.dp,
            borderColor = colorResource(id = R.color.main_blue),
            color = Color.Transparent
        ),
        selectorDotGraphic = DotGraphic(
            11.dp,
            color = colorResource(id = R.color.main_blue)
        )
    )

    LaunchedEffect(Unit) {
        snapshotFlow {
            Pair(
                horizontalState.currentPage,
                horizontalState.currentPageOffsetFraction
            )
        }.collect { (page, offset) ->
            verticalState.scrollToPage(page, offset)
        }
    }
    ...
}

pageCount 는 viewPager의 무한 스크롤 위해 만듬 변수이다. 대량의 page가 있어서 무한 스크롤이 가능하다.
viewPagerItems는 ViewPager의 content에 표시할 객체들이다.
indicatorType 는 Indicator의 스타일을 지정한 객체이다.
LaunchedEffect{} 블럭은 horizontalPager의 페이지와 Fraction을 계속 관찰하여 페이지와 Fraction이 바뀔때마다 verticalPager의 content가 자동으로 scroll 되게 구현함 코드이다.

HorizontalPager

HorizontalPager은 앞서 올렸던 포스팅이랑 별다른 점이 없다. 무한 스크롤을 추가한 부부만 설명하겠다.

HorizontalPager(
        state = horizontalState,
        modifier = modifier
            .fillMaxHeight(0.5f),
        pageSpacing = 20.dp,
        contentPadding = contentPadding,
    ) { page ->
        val realPage = page % viewPagerItems.size
        ViewPagerCard(
            page = page,
            realPage = realPage,
            viewPagerItems = viewPagerItems,
            horizontalState = horizontalState
        )
    }

HorizontalPager는 Int.MAX_VALUE(2,147,483,647) 개의 content가 있다.
page의 값은 2,147,483,647이고 realPage는 0 ~ 3 까지의 4인 범위를 가지고 있다.

DotsIndicator

저번 포스팅과 달리 androidx.compose.foundation.pager 패키지의 Pager와 호환되는 라이브러리를 추가했다.

DotsIndicator(
    dotCount = viewPagerItems.size,
    type = indicatorType,
    currentPage = horizontalState.currentPage % viewPagerItems.size,
    currentPageOffsetFraction = {
        if (horizontalState.currentPage % 4 == 0 && horizontalState.currentPageOffsetFraction <= 0f) {
            0f
        } else {
            horizontalState.currentPageOffsetFraction
        }
    }
)

dotCount는 Indicator의 개수, 즉 동그라미의 전체 개수이다.
currentPage은 현재 페이지에 따라 색칠되는 Indicator를 알기 위함이다.
currentPageOffsetFraction은 현재 페이지의 fraction에 따라 Indicator가 자동으로 움직이기 위함이다. () -> Float 타입으로 넣어줘야 하며 필자가 왜 저렇게 설정했는지 아래에서 설명하겠다.

DotsIndicator에서 이슈 발생

currentPageOffsetFraction을 아래와 같이 간단하게 현재 content의 fraction만 알려준다고 설정해보자.

currentPageOffsetFraction = {
    horizontalState.currentPageOffsetFraction
}

아래의 영상과 같은 문제가 생긴다. 4번 째 content에서 1번 째 content로 넘어갈 때 생긴 issue이다. 4번 째 content에서 1번 째 content로 넘어갈 때 1번 째 content는 -0.5f fraction에서 시작해 중앙에 위치하면 0f fraction으로 바뀌게 된다.
Indicator의 경우 동그라미가 색칠 되었을 때 해당 content가 0f fraction이 경우이고 좌측이 마이너스 영역이다.
이제 이 정도 설명했으면 모두가 감을 잡았을 거라고 생각한다!

  1. 4번 째 content에서 1번 째 content로 넘어갈 때
  2. 1번 째 content의 fraction이 -0.5f 이하일 때

위의 2가지 경우만 fraction을 0f로 설정하면 된다!


Fraction 설명

image

빨간색 글씨를 보면 알겠지만 현재 content1의 fraction은 0f이다.


image

좌측으로 스크롤 했을 떄 content1은 좌측으로 이동하고 content2 또한 좌측으로 이동하게 된다.
content1의 fraction은 플러스 값으로 변하게 된다.


image

마저 좌측으로 스크롤 했을 떄 content1content2의 사이 에 오게 되었다. content1의 fraction이 0.5f로 바뀜과 동시에 content2의 fraction이 -0.5f로 로그에 찍히게 된다.


image

이어서 좌측으로 스크롤 했을 떄 content2의 fraction은 -0.5f보다 작은 값인 음수로 로그에 찍힌다.


image

content2가 중앙으로 왔을 때 content2의 fraction은 0f가 된다.

전체 코드

  • OnBoardViewPager() 컴포저블 함수

    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun OnBoardViewPager(modifier: Modifier = Modifier) {
        val pageCount = Int.MAX_VALUE
    
        val viewPagerItems =
            listOf(ViewPagerItem.Item1, ViewPagerItem.Item2, ViewPagerItem.Item3, ViewPagerItem.Item4)
    
        val horizontalState = rememberPagerState { pageCount }
        val verticalState = rememberPagerState { pageCount }
        val contentPadding = PaddingValues(top = 50.dp, start = 70.dp, end = 70.dp)
        val indicatorType = SpringIndicatorType(
            dotsGraphic = DotGraphic(
                13.dp,
                borderWidth = 2.dp,
                borderColor = colorResource(id = R.color.main_blue),
                color = Color.Transparent
            ),
            selectorDotGraphic = DotGraphic(
                11.dp,
                color = colorResource(id = R.color.main_blue)
            )
        )
    
        LaunchedEffect(Unit) {
            snapshotFlow {
                Pair(
                    horizontalState.currentPage,
                    horizontalState.currentPageOffsetFraction
                )
            }.collect { (page, offset) ->
                verticalState.scrollToPage(page, offset)
            }
        }
        HorizontalPager(
            state = horizontalState,
            modifier = modifier
                .fillMaxHeight(0.5f),
            pageSpacing = 20.dp,
            contentPadding = contentPadding,
        ) { page ->
    //        Log.d(TAG, "page: ${horizontalState.currentPage}\nfraction: ${horizontalState.currentPageOffsetFraction}")
            val realPage = page % viewPagerItems.size
            ViewPagerCard(
                page = page,
                realPage = realPage,
                viewPagerItems = viewPagerItems,
                horizontalState = horizontalState
            )
        }
    
        Spacer(modifier = Modifier.height(16.dp))
        DotsIndicator(
            dotCount = viewPagerItems.size,
            type = indicatorType,
            currentPage = horizontalState.currentPage % viewPagerItems.size,
            currentPageOffsetFraction = {
                if (horizontalState.currentPage % 4 == 0 && horizontalState.currentPageOffsetFraction <= 0f) {
                    0f
                } else {
                    horizontalState.currentPageOffsetFraction
                }
            })
        Spacer(modifier = Modifier.height(20.dp))
        VerticalPager(
            state = verticalState,
            modifier = Modifier
                .fillMaxHeight(0.3f)
                .padding(horizontal = 70.dp),
            userScrollEnabled = false,
        ) { page ->
            val realPage = page % viewPagerItems.size
            Text(
                text = "$page ${viewPagerItems[realPage].explain}",
            )
        }
    }
    
  • ViewPagerCard() 컴포저블 함수

    @OptIn(ExperimentalFoundationApi::class)
    @Composable
    fun PagerScope.ViewPagerCard(
        page: Int,
        realPage: Int,
        viewPagerItems: List<ViewPagerItem>,
        horizontalState: PagerState
    ) {
    
        Card(
            Modifier
                .graphicsLayer {
                    val pageOffset = abs(horizontalState.getOffsetFractionForPage(page))
                    AnimationUtils
                        .lerp(
                            0.85f,
                            1f,
                            1f - pageOffset
                        )
                        .also { scale ->
                            this.scaleX = scale
                            this.scaleY = scale
    //                        Log.d(TAG, "page: ${page}\nfraction: ${pageOffset}\n scale: $scale\n translationX: $translationX")
                        }
    
                    // We animate the alpha, between 50% and 100%
                    this.alpha = AnimationUtils.lerp(
                        0.5f,
                        1f,
                        1f - pageOffset.coerceIn(0f, 1f)
                    )
                },
            shape = RoundedCornerShape(size = 20.dp),
            elevation = CardDefaults.cardElevation(5.dp),
            colors = CardDefaults.cardColors(
                containerColor = Color.White,
                contentColor = Color.Black
            )
        ) {
            Column(
                modifier = Modifier.fillMaxSize(),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.SpaceEvenly
            ) {
                Image(
                    painter = painterResource(id = viewPagerItems[realPage].image),
                    contentDescription = "",
                    modifier = Modifier.size(150.dp)
                )
                Text(
                    text = viewPagerItems[realPage].title,
                    fontWeight = FontWeight.SemiBold,
                    fontSize = 20.sp
                )
            }
        }
    }
    

References

compose ViewPager 무한 스크롤
참고한 indicator 라이브러리 예쁜 여러가지 ViewPager 디자인