본문 바로가기
Android/Compose

[Android] Compose에서의 Pager

by 태크민 2025. 5. 31.

Compose에서의 Pager

콘텐츠를 좌우 또는 상하로 넘기기 위해 각각 HorizontalPager와 VerticalPager 컴포저블을 사용할 수 있습니다. 이 컴포저블들은 기존 뷰 시스템의 ViewPager와 유사한 기능을 제공합니다.

기본적으로 HorizontalPager는 화면의 전체 너비를 차지하고, VerticalPager는 전체 높이를 차지하며, 한 번에 한 페이지만 플링(fling)되도록 설정되어 있습니다. 이러한 기본 동작은 모두 설정을 통해 변경할 수 있습니다.

 

HorizontalPager

좌우로 가로 스크롤되는 페이저를 만들기 위해서는 HorizontalPager를 사용하세요.

그림 1. HorizontalPager 데모
// Display 10 items
val pagerState = rememberPagerState(pageCount = {
    10
})
HorizontalPager(state = pagerState) { page ->
    // Our page content
    Text(
        text = "Page: $page",
        modifier = Modifier.fillMaxWidth()
    )
}

 

VerticalPager

상하로 스크롤되는 페이저를 만들기 위해서는 VerticalPager를 사용하세요.

그림 2. VerticalPager 데모
// Display 10 items
val pagerState = rememberPagerState(pageCount = {
    10
})
VerticalPager(state = pagerState) { page ->
    // Our page content
    Text(
        text = "Page: $page",
        modifier = Modifier.fillMaxWidth()
    )
}

 


지연 생성(Lazy creation)

HorizontalPager와 VerticalPager의 페이지는 모두 필요한 시점에 지연(래이지) 생성 및 레이아웃 처리됩니다. 사용자가 페이지를 스크롤하면, 더 이상 필요하지 않은 페이지는 자동으로 제거됩니다.

 

오프스크린 페이지 미리 로드하기

기본적으로 페이저는 화면에 보이는 페이지만 로드합니다. 화면 밖의 페이지도 미리 로드하려면 beyondBoundsPageCount 값을 0보다 크게 설정하세요.

 


페이저에서 특정 페이지로 스크롤하기

페이저에서 특정 페이지로 스크롤하려면 rememberPagerState()를 사용해 PagerState 객체를 생성하고, 이를 state 파라미터로 페이저에 전달하세요. 이후 CoroutineScope 내에서 PagerState.scrollToPage() 함수를 호출하여 원하는 페이지로 이동할 수 있습니다.

val pagerState = rememberPagerState(pageCount = {
    10
})
HorizontalPager(state = pagerState) { page ->
    // Our page content
    Text(
        text = "Page: $page",
        modifier = Modifier
            .fillMaxWidth()
            .height(100.dp)
    )
}

// scroll to page
val coroutineScope = rememberCoroutineScope()
Button(onClick = {
    coroutineScope.launch {
        // Call scroll to on pagerState
        pagerState.scrollToPage(5)
    }
}, modifier = Modifier.align(Alignment.BottomCenter)) {
    Text("Jump to Page 5")
}

 

페이지로 이동할 때 애니메이션 효과를 주고 싶다면, PagerState.animateScrollToPage() 함수를 사용하세요.

val pagerState = rememberPagerState(pageCount = {
    10
})

HorizontalPager(state = pagerState) { page ->
    // Our page content
    Text(
        text = "Page: $page",
        modifier = Modifier
            .fillMaxWidth()
            .height(100.dp)
    )
}

// scroll to page
val coroutineScope = rememberCoroutineScope()
Button(onClick = {
    coroutineScope.launch {
        // Call scroll to on pagerState
        pagerState.animateScrollToPage(5)
    }
}, modifier = Modifier.align(Alignment.BottomCenter)) {
    Text("Jump to Page 5")
}

 


페이지 상태 변경 감지하기

PagerState는 페이지에 대한 정보를 담고 있는 세 가지 속성을 제공합니다: currentPage, settledPage, targetPage.

  • currentPage: 스냅 위치에 가장 가까운 페이지입니다. 기본적으로 스냅 위치는 레이아웃의 시작 부분입니다.
    • 예: HorizontalPager의 경우, 왼쪽 가장자리.
  • settledPage: 애니메이션이나 스크롤이 진행되지 않을 때의 페이지 번호입니다. currentPage는 페이지가 스냅 위치에 충분히 가까우면 즉시 업데이트되지만, settledPage는 모든 애니메이션이 완료될 때까지 그대로 유지된다는 점에서 차이가 있습니다.
  • targetPage: 스크롤 동작의 예상 정지 위치입니다.

이 변수들의 변화를 관찰하고 이에 반응하려면 snapshotFlow 함수를 사용할 수 있습니다. 예를 들어, 페이지가 바뀔 때마다 분석 이벤트를 보내고 싶다면 다음과 같이 구현할 수 있습니다.

val pagerState = rememberPagerState(pageCount = {
    10
})

LaunchedEffect(pagerState) {
    // Collect from the a snapshotFlow reading the currentPage
    snapshotFlow { pagerState.currentPage }.collect { page ->
        // Do something with each page change, for example:
        // viewModel.sendPageSelectedEvent(page)
        Log.d("Page change", "Page changed to $page")
    }
}

VerticalPager(
    state = pagerState,
) { page ->
    Text(text = "Page: $page")
}
 

페이지 인디케이터 추가하기

페이지에 인디케이터를 추가하려면, PagerState 객체를 사용하여 현재 선택된 페이지전체 페이지 수에 대한 정보를 가져온 후, 커스텀 인디케이터를 직접 그리면 됩니다.

예를 들어, 간단한 원형 인디케이터를 만들고 싶다면, 전체 페이지 수만큼 원을 반복해서 그리고, 현재 선택된 페이지인지 여부에 따라 원의 색상을 변경하면 됩니다. 이때 pagerState.currentPage를 사용하여 현재 페이지를 확인할 수 있습니다.

val pagerState = rememberPagerState(pageCount = {
    4
})
HorizontalPager(
    state = pagerState,
    modifier = Modifier.fillMaxSize()
) { page ->
    // Our page content
    Text(
        text = "Page: $page",
    )
}
Row(
    Modifier
        .wrapContentHeight()
        .fillMaxWidth()
        .align(Alignment.BottomCenter)
        .padding(bottom = 8.dp),
    horizontalArrangement = Arrangement.Center
) {
    repeat(pagerState.pageCount) { iteration ->
        val color = if (pagerState.currentPage == iteration) Color.DarkGray else Color.LightGray
        Box(
            modifier = Modifier
                .padding(2.dp)
                .clip(CircleShape)
                .background(color)
                .size(16.dp)
        )
    }
}

그림 3. 콘텐츠 아래에 원형 인디케이터가 표시된 Pager

 


아이템 스크롤 효과 적용하기

일반적인 사용 사례 중 하나는 스크롤 위치를 기반으로 Pager 아이템에 효과를 적용하는 것입니다.

현재 선택된 페이지로부터 각 페이지가 얼마나 떨어져 있는지 확인하려면 PagerState.currentPageOffsetFraction을 사용할 수 있습니다.

이 값을 활용하여, 선택된 페이지로부터의 거리에 따라 콘텐츠에 다양한 변환 효과(트랜스포메이션) 를 적용할 수 있습니다.

그림 4. Pager 콘텐츠에 변환 효과를 적용한 모습

 

예를 들어, 아이템이 중앙에서 얼마나 떨어져 있는지에 따라 투명도(opacity)를 조절하고 싶다면, Pager 내부의 아이템에 Modifier.graphicsLayer를 사용하여 alpha 값을 변경하면 됩니다.

 


사용자 정의 페이지 크기

기본적으로 HorizontalPager와 VerticalPager는 각각 전체 너비 또는 높이를 차지합니다.
하지만 pageSize 변수를 설정하여 페이지 크기를 다음과 같이 지정할 수 있습니다:

  • Fixed: 고정된 크기
  • Fill: 전체 공간을 채움 (기본값)
  • 사용자 정의 크기 계산
val pagerState = rememberPagerState(pageCount = {
    4
})
HorizontalPager(
    state = pagerState,
    pageSize = PageSize.Fixed(100.dp)
) { page ->
    // page content
}

 

페이지를 뷰포트 내에 3개씩 보이도록 하고 싶다면, 커스텀 PageSize 객체를 만들어서 사용하세요.
아래는 아이템 간 간격(pageSpacing)을 고려하여 전체 공간을 3등분하는 방법입니다:

private val threePagesPerViewport = object : PageSize {
    override fun Density.calculateMainAxisPageSize(
        availableSpace: Int,
        pageSpacing: Int
    ): Int {
        return (availableSpace - 2 * pageSpacing) / 3
    }
}

 


콘텐츠 패딩 (Content padding)

HorizontalPager와 VerticalPager는 모두 콘텐츠 패딩(content padding)을 조절할 수 있으며, 이를 통해 페이지의 최대 크기와 정렬 방식에 영향을 줄 수 있습니다.

예를 들어, 시작 부분(start)에 패딩을 설정하면, 페이지가 끝 방향(end)으로 정렬됩니다.

val pagerState = rememberPagerState(pageCount = {
    4
})
HorizontalPager(
    state = pagerState,
    contentPadding = PaddingValues(start = 64.dp),
) { page ->
    // page content
}

 

시작 패딩과 끝 패딩을 동일한 값으로 설정하면, 아이템이 수평 중앙에 정렬됩니다.

val pagerState = rememberPagerState(pageCount = {
    4
})
HorizontalPager(
    state = pagerState,
    contentPadding = PaddingValues(horizontal = 32.dp),
) { page ->
    // page content
}

 

끝 패딩을 설정하면, 페이지가 시작 방향으로 정렬됩니다.

val pagerState = rememberPagerState(pageCount = {
    4
})
HorizontalPager(
    state = pagerState,
    contentPadding = PaddingValues(end = 64.dp),
) { page ->
    // page content
}

 

VerticalPager에서도 유사한 효과를 얻기 위해 상단(top)과 하단(bottom) 패딩 값을 설정할 수 있습니다.

여기서 사용된 32.dp는 단지 예시일 뿐이며, 각 패딩 값은 원하는 대로 설정할 수 있습니다.


스크롤 동작 커스터마이징 하기

기본적으로 HorizontalPager와 VerticalPager는 스크롤 제스처가 어떻게 작동할지 미리 정의되어 있습니다.
하지만 이를 커스터마이징하여, 예를 들어 pagerSnapDistance나 flingBehavior와 같은 기본 동작을 변경할 수 있습니다.

Snap 거리 (Snap distance)

기본적으로 HorizontalPager와 VerticalPager는 한 번의 플링(fling) 제스처한 페이지만 넘어가도록 설정되어 있습니다.

이 동작을 변경하려면, flingBehavior에서 pagerSnapDistance를 설정하면 됩니다:

val pagerState = rememberPagerState(pageCount = { 10 })

val fling = PagerDefaults.flingBehavior(
    state = pagerState,
    pagerSnapDistance = PagerSnapDistance.atMost(10)
)

Column(modifier = Modifier.fillMaxSize()) {
    HorizontalPager(
        state = pagerState,
        pageSize = PageSize.Fixed(200.dp),
        beyondViewportPageCount = 10,
        flingBehavior = fling
    ) {
        PagerSampleItem(page = it)
    }
}

 

위 코드는 한 번의 빠른 스와이프(플링) 동작으로 최대 10페이지까지 이동할 수 있도록 설정한 예제입니다. (기본값은 1페이지)

즉, 다음과 같은 방식으로 동작합니다:

  • 사용자가 강하게 플링(빠르게 스와이프)할 경우 → 최대 10페이지까지 이동
  • 살짝 스와이프할 경우 → 1~2페이지만 이동

또한 beyondViewportPageCount를 10으로 설정한 것은, 사용자가 스크롤하기 전에 화면에 보이지 않는 인접 페이지들도 미리 렌더링하도록 하기 위함입니다.
이를 설정하지 않으면, 스와이프 시점에 페이지를 그리기 때문에 전환이 지연되거나 끊기는 듯한 인상을 줄 수 있습니다.

따라서 pagerSnapDistance와 beyondViewportPageCount를 함께 설정하면, 더 빠른 스와이프에도 부드럽고 자연스러운 페이지 전환 경험을 제공할 수 있습니다.

 


자동 넘김 Pager 만들기

이 섹션에서는 페이지 인디케이터가 포함된 자동 넘김 Pager를 Compose에서 만드는 방법을 설명합니다.
아이템 목록이 자동으로 가로로 스크롤되지만, 사용자가 직접 스와이프하여 페이지를 넘길 수도 있습니다.
단, 사용자가 Pager를 조작하면 자동 진행이 중단됩니다.

 

기본 예제

아래 코드 조각들은 각 페이지가 서로 다른 색상으로 표시되며, 자동으로 넘어가는 Pager와 시각적 인디케이터를 포함한 기본 자동 진행 Pager 구현을 보여줍니다.

@Composable
fun AutoAdvancePager(pageItems: List<Color>, modifier: Modifier = Modifier) {
    Box(modifier = Modifier.fillMaxSize()) {
        val pagerState = rememberPagerState(pageCount = { pageItems.size })
        val pagerIsDragged by pagerState.interactionSource.collectIsDraggedAsState()

        val pageInteractionSource = remember { MutableInteractionSource() }
        val pageIsPressed by pageInteractionSource.collectIsPressedAsState()

        // Stop auto-advancing when pager is dragged or one of the pages is pressed
        val autoAdvance = !pagerIsDragged && !pageIsPressed

        if (autoAdvance) {
            LaunchedEffect(pagerState, pageInteractionSource) {
                while (true) {
                    delay(2000)
                    val nextPage = (pagerState.currentPage + 1) % pageItems.size
                    pagerState.animateScrollToPage(nextPage)
                }
            }
        }

        HorizontalPager(
            state = pagerState
        ) { page ->
            Text(
                text = "Page: $page",
                textAlign = TextAlign.Center,
                modifier = modifier
                    .fillMaxSize()
                    .background(pageItems[page])
                    .clickable(
                        interactionSource = pageInteractionSource,
                        indication = LocalIndication.current
                    ) {
                        // Handle page click
                    }
                    .wrapContentSize(align = Alignment.Center)
            )
        }

        PagerIndicator(pageItems.size, pagerState.currentPage)
    }
}

 

코드의 핵심 포인트

  • AutoAdvancePager 함수는 자동으로 페이지가 넘어가는 가로 방향 Pager 뷰를 생성합니다.
    이 함수는 각 페이지의 배경색으로 사용될 Color 객체 리스트를 입력으로 받습니다.
  • pagerState는 rememberPagerState를 사용해 생성되며, Pager의 상태를 관리합니다.
  • pagerIsDragged와 pageIsPressed는 **사용자의 상호작용 여부(드래그 또는 클릭)**를 추적합니다.
  • LaunchedEffect는 사용자가 드래그하거나 페이지를 누르지 않는 동안, 2초마다 자동으로 Pager를 넘깁니다.
  • HorizontalPager는 여러 페이지를 표시하며, 각 페이지는 페이지 번호가 적힌 Text 컴포저블을 포함합니다.
    Modifier를 통해 페이지는 전체 영역을 채우고, pageItems에서 지정한 색상으로 배경이 설정되며, 클릭도 가능합니다.
@Composable
fun PagerIndicator(pageCount: Int, currentPageIndex: Int, modifier: Modifier = Modifier) {
    Box(modifier = Modifier.fillMaxSize()) {
        Row(
            modifier = Modifier
                .wrapContentHeight()
                .fillMaxWidth()
                .align(Alignment.BottomCenter)
                .padding(bottom = 8.dp),
            horizontalArrangement = Arrangement.Center
        ) {
            repeat(pageCount) { iteration ->
                val color = if (currentPageIndex == iteration) Color.DarkGray else Color.LightGray
                Box(
                    modifier = modifier
                        .padding(2.dp)
                        .clip(CircleShape)
                        .background(color)
                        .size(16.dp)
                )
            }
        }
    }
}

 

 

코드의 핵심 포인트

  • Box 컴포저블이 최상위 루트 요소로 사용됩니다.
  • Box 내부에는 Row 컴포저블이 있어 페이지 인디케이터를 수평으로 배치합니다.
  • 커스텀 페이지 인디케이터는 여러 개의 원으로 구성된 행(row)으로 표시되며, 각 Box가 원형으로 잘려(clipped) 하나의 페이지를 나타냅니다.
  • 현재 페이지에 해당하는 원은 **진한 회색(DarkGray)**으로 표시되고, 나머지 원은 **연한 회색(LightGray)**으로 표시됩니다.
  • currentPageIndex 파라미터는 어떤 원이 **진한 회색(선택된 상태)**으로 렌더링될지를 결정합니다.

결과

아래 비디오는 위에서 설명한 코드 스니펫들을 통해 구현된 기본 자동 진행 Pager의 동작을 보여줍니다.

그림 1. 각 페이지가 2초 간격으로 자동 전환되는 자동 진행 Pager

 

 

 

끝.


참고자료

https://developer.android.com/develop/ui/compose/layouts/pager?_gl=1*nrjz25*_up*MQ..*_ga*MTQyMzEzNTM3OC4xNzQ4Njg3MjY0*_ga_6HH9YJMN9M*czE3NDg2ODcyNjQkbzEkZzAkdDE3NDg2ODcyNjQkajYwJGwwJGg2MDc1NDUyODc.#customize-scroll