본문 바로가기
안드로이드 공부 노트/Compose(컴포즈)

[Android-Compose] Compose 스와이프 가능한 탭 UI 만들기 - 2편HorizontalPager

by 지게요 2025. 12. 28.
728x90
반응형

이번 공부 노트는 저번 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를 사용하면 된다.

파라미터 구성과 사용법이 완전히 똑같기 때문에 방향만 세로로 바뀐다고 생각하면 아주 쉽게 적용할 수 있다!

 

평소 단순히 페이지를 넘기는 용도로만 페이저를 써왔다면 이번 기회에 내부 파라미터들의 역할을 제대로 짚어볼 수 있었다. 자주 쓰이지 않는 속성이라도 그 원리를 이해하는 것이 복잡한 스크롤 환경에서 발생하는 이슈를 해결하는 열쇠가 된다는 점을 배운 거 같다!

 

글을 읽으시는 분들은 어떤 파라미터를 유용하게 사용하고 있는지 궁금하다!

 

 

반응형