본문 바로가기

Android/Jetpack Compose

[Compose] Compose 환경에서 Circular Scroll Pager와 다른 UI를 가지는 Pager를 구현해보자.

728x90

Compose 환경에서는 Pager를 아주 쉽게 구현을 할 수 있다.

하지만, 실무에서 적용하기 위해서는 Pager를 구현하기만 하면 되는 것이 아니라 몇 가지 기본적인 기능을 추가해야 사용이 가능하다.

 

따라서, 몇 가지 기본적인 기능 중 가장 많이 사용이 되는 두 가지 기능에 대해서 설명하고자 한다.

 

첫 번째는

Circular Scroll이 가능한 Pager처럼 보이도록,

첫 번째, 마지막 index에서 좌 우로 이동하려고 했을 때 각각 마지막, 첫 번째 index의 아이템을 보여주는 방법이고

 

두 번째는

Pager로 화면을 스크롤할 때 각각 다른 Compose UI를 보여주도록 설정하는 방법이다.

 

다양한 방법으로 2가지를 모두 구현할 수 있는데 필자가 적용한 방법은 비교적 간단한 방법이라고 생각되며,

복잡하지 않은 로직에 해당 기능만 구현이 필요하다. 하는 경우에 사용할만할 것 같아 글을 작성하려 한다.


우선,

Full Screen Pager를 구현하는 방법은 아주 간단하다.

Box(modifier = Modifier.fillMaxSize()) {
    HorizontalPager(
        pageCount = textList.size,
        state = pagerState
    ) {
        TextPager(text = textList[pagerState.currentPage])
    }

    PagerDotUI(
        itemSize = textList.size,
        pagerState = pagerState
    )
}

 

@Composable
fun TextPager(text: String) {
    Box(modifier = Modifier.fillMaxSize()) {
        Column(
            modifier = Modifier.align(Alignment.Center)
        ) {
            Text(text = text)
        }
    }
}

 

이처럼 fillMaxSize의 Item을 넣어서 Pager를 구현하면 된다.

 

여기서 DotUI를 만드는 Component는 별도로 구현해 두었는데, 해당 컴포넌트는 다음과 같다.

@Composable
fun BoxScope.PagerDotUI(
    itemSize: Int,
    pagerState: PagerState,
) {
    Row(
        Modifier
            .height(48.dp)
            .fillMaxWidth()
            .align(Alignment.BottomCenter),
        horizontalArrangement = Arrangement.Center
    ) {
        repeat(itemSize) { index ->
            val color =
                if (pagerState.currentPage == index) Color.DarkGray else Color.LightGray
            Box(
                modifier = Modifier
                    .padding(4.dp)
                    .clip(CircleShape)
                    .background(color)
                    .size(10.dp)
            )
        }
    }
}

 

modifier와 정렬 부분은 상황에 맞춰서 수정하면 되고, 중요한 것은 repeat으로 구현한 부분이다.

Dot의 개수는 당연하다시피 item의 사이즈만큼 필요하므로, repeat을 사용하여 item 사이즈만큼 반복하여 ui를 그려주었고, Box를 통해 점을 만들어 주었다.

여기서 중요한 부분은 선택된 색상을 변경해 주는 부분인데, pagerState를 사용하여 변경하도록 하였다.

 

스크롤을 할 때마다 pagerState의 currentPage 값이 변경되게 되며, 그 값과 dot의 index 값과 같으면 현재 보고 있는 UI의 index란 뜻이 되므로 해당 index의 dot의 색상을 변경하도록 하였다.


UI 설명은 여기까지 하도록 하고, 첫 번째 기능에 대해서 설명하도록 하겠다.

 

Circular Scroll는 말 그대로 보여줄 아이템을 원형으로 이어 붙여, 무한히 스크롤을 진행해도 가지고 있는 정해진 개수의 데이터를 보여주는 방법이다.

자주 사용되는 기능이기 때문에 Compose Pager에서는 해당 기능을 제공해주지 않을까?라는 생각을 했었는데 아직 해당 기능은 제공해주지 않는 것으로 보여서 직접 구현하고자 하였다.

 

필자는 LaunchedEffect와 pagerState를 사용하여 해당 기능을 구현하였다.

val pagerState = rememberPagerState()
var scrollLastPage by remember { mutableStateOf(false) }

// index : last -> first
LaunchedEffect(key1 = pagerState.isScrollInProgress) {
    if (pagerState.currentPage == textList.size - 1
        && pagerState.isScrollInProgress
        && pagerState.currentPageOffsetFraction == 0f
    ) {
        scrollLastPage = true
    }

    if (scrollLastPage) {
        pagerState.animateScrollToPage(0)
        scrollLastPage = false
    }
}

 

LaunchedEffect의 key 값으로 pagerSate.isScrollInprogress를 넣어서 해당 데이터를 옵저빙 하였다.

pagerState만 넣으면 되지 않을까? 싶겠지만, pagerState 객체 자체는 최초에 한번 생성되고 난 후에 객체 내부의 데이터만 변경되기 때문에 LaunchedEffect가 호출되지 않는다.

따라서 state 객체가 아닌 변경되는 내부의 데이터를 옵저빙 해서 사용해야 한다.

 

여기서 많이 사용하는 데이터는 currentPage를 사용한다.

필자도 해당 데이터를 기반으로 구현을 해보려고 했으나, page 값만 사용하여 구현하게 되면 마지막 index에 도달하면 바로 첫 번째 index로 변경이 되기 때문에 우리가 원하는 방식대로의 구현은 되지 않는다.

 

그렇기 때문에 isScrollInProgress 데이터를 사용하였는데, 해당 값은 현재 스크롤이 되고 있는가? 에 대한 여부를 체크하는 값이다.

 

필자는 해당 데이터를 발견하고 key1에는 currentPage 값을, key2에는 isScrollInprogress 값을 넣어서 구현하고자 하였는데,

이렇게 2가지의 key를 사용하는 경우, 원하지 않는 타이밍에도 LaunchedEffect가 실행되어 원하지 않는 이벤트에 페이지가 넘어가지는 이슈가 발생하였다.

 

따라서, isScrollInProgress 하나만 key 값으로 사용하여 구현을 하였다.

 

key 값은 알겠는데, scrollLastPage 값은 왜 쓴 거야?

라고 생각할 수 있다.

 

필자도 처음엔 해당 flag값을 사용하지 않고 그냥 바로 scroll 되도록 구현하려 했었다.

하지만, isScrollInProgress 데이터는 "스크롤 도중"에 true 값을 갖고 있고, 이때 페이지가 첫 번째로 다시 scroll 되어야 한다.

이때, 스크롤 도중에는 사용자가 직접 UI를 컨트롤하고 있어 UI를 변경하는 함수가 무시된다.

 

해당 부분에 log를 찍어보면 알 수 있겠지만, scrollLastPage = true라고 선언된 부분에 animateScrollToPage 함수를 넣게 되면 해당 부분이 호출되지 않는다.

 

마지막으로,

currentPageOffsetFraction 값을 사용한 이유에 대해서 설명하고자 한다.

 

해당 조건이 없는 상태에서 생각을 해보면,

마지막 페이지에서 이전 페이지로 스크롤하는 경우에도 첫 번째 페이지로 이동하게 된다.

우리는 현재 페이지에서 다음 페이지로 넘어갔을 경우에만 첫 번째 페이지로 이동하기를 원하기 때문에, 이 경우만 캐치할 수 있는 flag 값이 필요하게 된다.

 

따라서, currentPageOffsetFraction 값을 사용하는 것을 선택하였는데, 해당 값은 현재 페이지가 움직이는 offset 값을 알 수 있다.

즉, 다음 페이지로 넘어가는 것은 해당 값이 양수로 찍히게 되고, 이전 페이지로 넘어가는 것은 해당 값이 음수로 찍히게 된다.

 

사이사이 찍히는 0.0 값은 페이지 변경이 완료되었을 때 찍히는 값으로, key 값을 isScrollInProgress로 했기 때문에 찍히는 값들이라고 보면 된다.

 

그렇다면 왜 0f로 비교를 했는가?

이 부분은 개선할 여지가 많은 부분이긴 하지만, 우선 그 이유는 이동하지 못하는 곳으로 스크롤을 하게 되면, 현재 페이지가 움직이지 않기 때문에 offset 값은 0f가 찍히게 되기 때문이다.

 

이 부분이 개선할 여지가 많다고 한 이유는,

다른 페이지에서 스크롤할 때와 비교해서 상당히 짧은 거리를 스크롤해도 다른 페이지로 넘어간다는 것과,

0f 값이 다른 케이스에서도 발생할 여지가 충분하기 때문에, 원하지 않는 동작을 할 케이스가 생길 수 있기 때문이다.

 

따라서, 간단하게 구현이 가능하고 사용이 가능하긴 하지만,

이슈가 발생하면 케이스에 따라서 해당 값을 수정하여 정확한 Flag값으로 설정할 수 있다면 문제없는 Circular Scroll을 구현할 수 있을 것이다.


다음으로,

여러 가지 UI를 가지는 Pager를 구현하는 방법이다.

 

이 방법은 Compose가 가지고 있는 특성을 이용한 아주 간단한 방법이다.

data class DisplayItem(
    val display: @Composable () -> Unit
)

...

val exampleScreenList = listOf(
    DisplayItem { FirstScreen() },
    DisplayItem { SecondScreen() },
    DisplayItem { ThirdScreen() },
    DisplayItem { LastScreen() }
)

 

val pagerState = rememberPagerState()

Box(modifier = Modifier.fillMaxSize()) {
    HorizontalPager(
        pageCount = exampleScreenList.size,
        state = pagerState
    ) {
        exampleScreenList[pagerState.currentPage].display()
    }

    PagerDotUI(
        itemSize = exampleScreenList.size,
        pagerState = pagerState
    )
}

 

ListArray에 Compose 함수를 담고, currentPage에 해당하는 index의 compose UI를 보여주면 된다.

 

여기서,

val exampleScreenList = listOf(
    FirstScreen(),
    SecondScreen(),
    ThirdScreen(),
    LastScreen()
)

 

이처럼 Compose 함수를 바로 List에 넣어서 사용하지 않은 이유는,

적용해 보면 한 번에 알 수 있겠지만 위처럼 선언하는 것만으로 해당 UI가 모두 겹쳐져 그려져서 우리가 원하는 Pager의 기능을 하지 못한다.

 

DisplayItem이라는 객체로 감싼 후, display라는 람다를 호출했을 때만 해당 UI가 그려지도록 해야지만 우리가 원하는 대로 하나의 page를 그리고, Pager를 통해 스크롤이 가능하게 된다.

 

display 값에는 Composable 함수가 들어가면 되기 때문에, 원하는 UI를 다양하게 그려서 넣어주고,

이를 list로 집어넣고 스크롤하게 되면 다양한 UI를 가지는 Pager가 되는 것이다.

 

단순한 Image나 Text만 넣어서 Pager를 구현하는 경우, 첫 번째 구현하는 방식으로 내부 데이터만 변경해도 되지만,

경우에 따라 다른 UI를 보여줘야 하면 이와 같이 구현하여 사용이 가능하다.


이것으로 Pager와 자주 사용하는 2가지 기능의 구현을 해보았다.

두 기능 모두 자주 사용하고, 많은 부분에서 사용되고 있는 기능들이지만 실제로 처음 구현하려고 하면 어떻게 해야 하나 생각보다 어떻게 구현해야 할지 갈피가 잘 잡히지 않는 기능들이었던 것 같다.

 

Circular Scroll과 같은 경우, 개선해야 할 부분이 좀 보이기도 하고 

maxSize가 아닌 경우 다음 item이 계속해서 좌, 우에 보여야 하는 UI라고 한다면 다른 방법으로 구현을 해야 하는 등

아직 부족한 부분들이 많이 보인다.

해당 기능들이 다시 필요해진다면, 별개로 다시 기능을 구현하여 글을 작성하도록 하겠다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample

Contribute to HeeGyeong/ComposeSample development by creating an account on GitHub.

github.com

 

728x90