이번 공부 노트는 저번 1편에 이어서 2편인 HorizontalPager에 대해 알아보도록 하겠다.
1편에서는 탭을 만들었고 이번에는 그 탭에 들어갈 화면을 만들어야 한다. 그 화면은 HorizontalPager를 통해 만들면 훨씬 간편하게 구현이 가능하다.
본 예제는 Material Design3을 따른다.
# HorizontalPager
- 화면이 좌우로 전환되는 UI를 구성할 때 자주 사용하는 컴포저블이다.
XML 뷰 시스템의 ViewPager와 유사한 기능을 한다.
@Composable
@ExperimentalFoundationApi
fun HorizontalPager(
state: PagerState,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(0.dp),
pageSize: PageSize = PageSize.Fill,
beyondBoundsPageCount: Int = PagerDefaults.BeyondBoundsPageCount,
pageSpacing: Dp = 0.dp,
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically,
flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state),
userScrollEnabled: Boolean = true,
reverseLayout: Boolean = false,
key: ((index: Int) -> Any)? = null,
pageNestedScrollConnection: NestedScrollConnection = remember(state) {
PagerDefaults.pageNestedScrollConnection(state, Orientation.Horizontal)
},
pageContent: @Composable PagerScope.(page: Int) -> Unit
) {
Pager(
...
...
)
}
위 코드는 HorizontalPager의 내부 코드이다.
이번에도 주요 파라미터를 하나하나 알아보자!
## state
- PagerState형인 Pager의 현재 상태를 관리하는 핵심 객체이다.
### PagerState
PagerState는 ViewPager에서 currentItem처럼, Pager의 현재 페이지, 오프셋 등을 기억하는 역할을 한다.
PagerState객체는 아래와 같이 rememberPagerState를 통해 PagerState객체를 생성한다.
val pagerState = rememberPagerState( pageCount = { 3 } )
@ExperimentalFoundationApi
@Composable
fun rememberPagerState(
initialPage: Int = 0,
initialPageOffsetFraction: Float = 0f,
pageCount: () -> Int
): PagerState {
return rememberSaveable(saver = PagerStateImpl.Saver) {
PagerStateImpl(
initialPage,
initialPageOffsetFraction,
pageCount
)
}.apply {
pageCountState.value = pageCount
}
}
rememberPagerState는 총 3가지의 파라미터가 있다.
- initialPage (선택 기본값 0) : Pager가 시작할 초기 페이지 인덱스
- initialPageOffsetFraction(선택 기본값 0f) : Pager 초기 오프셋 비율 (0f = 정렬, 0.5f = 절반 스크롤)
- pageCount(필수) : Pager의 전체 페이지 수를 반환하는 람다. 페이지 수가 동적으로 변할 수 있는 경우에 유용
## contentPadding
- Pager 전체에 적용할 패딩 값을 지정해 준다.
contentPadding = PaddingValues(20.dp)
모든 영역의 20.dp 만큼 패딩을 줬을 때 아래 화면처럼 나타난다.

## pageSize
- 각 페이지의 크기를 지정한다.
기본값으로는 PageSize.Fill이 적용되어 있다. PageSize의 옵션으로는 2가지가 있다.
PageSize.Fill - 한 화면에 한 페이지 즉 전체 너비를 채운다.
PageSize.Fixed - 페이지 크기를 원하는 Dp로 고정할 수 있다.
PageSize.Fixed을 300dp로 적용했을 시 아래 화면처럼 보인다.
pageSize = PageSize.Fixed(300.dp)

보다시피 각 페이지의 크기가 300dp로 고정되면서 남는 부분은 다음 페이지로 채우기 때문에 다음 페이지가 자연스럽게 보인다.
## beyondBoundsPageCount
- 현재 보이는 페이지 외에 미리 로드할 페이지 수
예를 들어 5개의 페이지 pager에 아래와 같이 beyondBoundsPageCount를 1로 설정했다고 예시를 들어 보겠다.
beyondBoundsPageCount = 1
초기 상태 (페이지 0)
[🟢 0] [⚪ 1] [ 2 ] [ 3 ] [ 4 ]...
현재 로드됨 안됨 안됨 안됨
페이지 1로 이동
[⚪ 0] [🟢 1] [⚪ 2] [ 3 ] [ 4 ]...
로드됨 현재 로드됨 안됨 안됨
페이지 2로 이동
[ 0 ] [⚪ 1] [🟢 2] [⚪ 3] [ 4 ]...
안됨 로드됨 현재 로드됨 안됨
페이지 3으로 이동
[ 0 ] [⚪ 1] [⚪ 2] [ 🟢3] [ 4 ]...
안됨 안됨 로드됨 현재 로드됨
위와 같이 앞뒤 페이지를 해당 수만큼 미리 로드를 하는 속성이다.
미리 로드하는 개수가 많아질수록 스크롤이 부드러워진다. 하지만 너무 많아지면 메모리를 한 번에 많이 사용하기 때문에 적절하게 조절하는 것이 좋다!
## pageSpacing
- 페이지 간의 간격을 나타낸다
기본값으로는 0.dp이다.
<50dp>

해당 값을 50dp로 줬을 때 보면 스크롤 시 검은색 영역이 간격을 나타낸다!
보통 pageSize와 함께 사용하면 좋은 효과를 나타낸다.
## flingBehavior
- 모든 스크롤 가능한 Composable에 공통으로 있는 파라미터이다.
- 쉽게 정리하자면 사용자가 페이지를 빠르게 스와이프(fling)했을 때의 물리적 동작을 제어하는 역할을 한다.
설정을 바꾸면 스와이프 속도에 따라 이동 거리 달라지기 때문에 긴 리스트/많은 페이지 있는 화면에서 사용하면 좋다!
이 부분을 깊게 설명을 하면 많이 길어질 거 같아서 설명은 생략하겠다.
## userScrollEnabled
- 영어 해석 그대로 사용자의 스크롤을 제어할 수 있는 파라미터이다.
userScrollEnabled = true (기본값) - 스크롤 가능
userScrollEnabled = false - 스크롤 불가능
다만 false이더라도 프로그래밍 방식으로는 이동 가능 하다!(animateScrollToPage, scrollToPage)
자동 슬라이드쇼, 버튼으로만 제어하는 페이저 온보딩 등 각 기획의도의 맞출 수 있을 때 유용하게 쓰인다.
## reverseLayout
- 페이지의 배치 순서를 반대로 뒤집는 파라미터이다!
reverseLayout = false(기본값) - 왼쪽 → 오른쪽
reverseLayout = true - 오른쪽 → 왼쪽
여기서 한 가지 알아야 할 점은 reverseLayout가 true라고 해서 인덱스도 같이 바뀌는 건 아니다.
reverseLayout는 시각적 배치만 뒤집고 인덱스는 변하지 않는다.
<reverseLayout = true>

영상에서 보다시피 처음 실행 시 -> 오른쪽 스와이프가 안 되는 모습이고 반대로 <- 왼쪽으로 스와이프가 되는 모습으로 보인다!
보기 쉽게 그려보자면 다음과 같다 [Item 3] [Item 2] [Item 1] ← 스크롤 시작
스크롤 시작 위치만 바꿔준다고 생각하면 된다! Item 1의 indext = 0 이 될 것이다.
## key
- 각 페이지(아이템)를 고유하게 식별하기 위한 파라미터이다.
- 이것도 flingBehavior와 같이 모든 스크롤 가능한 Composable에 공통으로 있는 파라미터이다.
- 페이지가 추가/삭제/재정렬될 때, 페이지에 remember 상태가 있을 때 사용한다.
예를 들어 해당 페이지들의 remember 값이 있다고 상황을 가정해 보자 key를 사용하지 않으면 각 remember 상태 값을 위치(0,1,2)로 저장한다.
하지만 key를 지정해 주면 지정한 값 (고유의 Id)으로 저장한다.
글로 이해하기보다는 아래 Key를 사용하지 않으면 발생하는 오류 예제를 보자.
var items by remember {
mutableStateOf(listOf("김지게", "이지게", "박지게"))
}
각 페이지의 들어갈 이름 remember 상태값이 있다.
val pagerState = rememberPagerState(
pageCount = { items.size }
)
Column(modifier = Modifier.fillMaxSize()) {
HorizontalPager(
state = pagerState,
// key 없음!
modifier = Modifier.weight(1f)
) { index ->
val likes = remember {
when(index) {
0 -> 5 // 김지게
1 -> 3 // 이지게
2 -> 7 // 박지게
else -> 0
}
}
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = items[index], fontSize = 48.sp)
Text(text = "좋아요: $likes", fontSize = 32.sp)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { items = items.filterIndexed { i, _ -> i != index } },
colors = ButtonDefaults.buttonColors(containerColor = Color.Red)
) {
Text("삭제", fontSize = 20.sp)
}
}
}
}
우선 전체 코드는 위와 같다 구조는 각 사람마다 다른 좋아요 개수가 있는데 만약 Key를 지정하지 않고 해당 페이지(데이터)를 삭제할 경우 영상과 같이 다음 아이템이 이전 아이템의 remember 상태를 물려받는 문제를 보여준다.
이지게 삭제 -> 다음 아이템인 박지게 가 이지게 좋아요 수인 3을 물려받음 -> 오동작!

---- Key 추가 예제 ----
HorizontalPager(
state = pagerState,
// 아이템의 이름으로 key 선정
key = { index -> items[index] },
modifier = Modifier.weight(1f)
) { index ->
val name = items[index]
val likes = remember {
when(name) { // name으로 구분!
"김지게" -> 5
"이지게" -> 3
"박지게" -> 7
else -> 0
}
}
}
해당 예제는 아이템의 이름으로 key를 선정해서 적용해 줬다.
key를 지정해 주니 확실히 중간 아이템이 삭제가 되어도 이전 아이템의 remember 상태를 물려받는 오동작이 일어나지 않는다!

❗️위 예시는 각 이름값이 고유하다는 조건을 걸었기 때문에 위처럼 key를 이름값으로 넣었던 것이다.
실무에서는 아이템마다 고유의 id를 만들어서 사용하는 것을 권장한다.
## pageNestedScrollConnection
- 중첩된 스크롤 구조에서 부모와 자식 간의 스크롤 우선순위를 조율한다.
- 사용자에게 끊김 없고 자연스러운 스와이프 경험을 제공하는 역할
이것만으로도 블로그 글을 한 페이지 써야 할 정도로 복잡하고 길어지기 때문에 간략히 설명 후 넘어가도록 하겠다.
사용하는 이유에 대해서는 만약 중첩된 스크롤 구조에서 유저가 대각선으로 화면을 밀었을 때 이게 가로 스크롤인지 세로 스크롤인지를 판별하기 위해서 사용을 한다.
자세한 사항은 아래 여기어때 기술 블로그에서 해당 파라미터를 커스텀해서 개성 있는 인터랙션을 만드는 과정을 자세하게 설명해 놨으니 더 알아보실 분들은 이 링크에서 학습을 권장한다!
## pageContent
- Pager의 각 페이지에 어떤 UI를 보여줄지 정의하는 필수 파라미터다.
- 현재 보여지고 생성되고 있는 페이지의 인덱스(Int형)를 전달받을 수 있다.
간단하게 설명하면 보여주고 싶은 UI를 이 안에 넣으면 된다.
아래 간단한 예제처럼 page 인덱스에 따라 각기 다른 컴포저블로 호출하면 더욱더 깔끔하게 구현이 가능하다!
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
) { index ->
// page 인덱스에 따라 각기 다른 컴포저블 호출
when (index) {
0 -> AScreen()
1 -> BScreen()
2 -> CScreen()
}
}

HorizontalPager를 알아보았는데 만약 세로형 페이저를 만들고 싶다면 VerticalPager를 사용하면 된다.
파라미터 구성과 사용법이 완전히 똑같기 때문에 방향만 세로로 바뀐다고 생각하면 아주 쉽게 적용할 수 있다!
평소 단순히 페이지를 넘기는 용도로만 페이저를 써왔다면 이번 기회에 내부 파라미터들의 역할을 제대로 짚어볼 수 있었다. 자주 쓰이지 않는 속성이라도 그 원리를 이해하는 것이 복잡한 스크롤 환경에서 발생하는 이슈를 해결하는 열쇠가 된다는 점을 배운 거 같다!
글을 읽으시는 분들은 어떤 파라미터를 유용하게 사용하고 있는지 궁금하다!