[Android] ViewPager를 적용해 다양한 UI 만들어보기

윤찬·2025년 7월 23일

Android

목록 보기
9/37

목표 UI

요즘 게임은 잘 안하지만 예전에 던파를 좋아했던 사람으로 던파 관련 앱이 하나 있다.
던파ON 이라는 앱인데, 해당 앱에서 아래와 같은 UI가 메인에 있는 것을 보고 나도 한 번 만들어볼까 생각해서 시작하게 되었다.

첫 번째는 자동으로 스크롤이 되는 UI이다. 이 앱 뿐만아니라 실제로도 커머스 앱이나 다른 앱에서도 많이 사용하는 방식이다.

두 번 째 방식은 스크롤로 이미지를 보여주는 방식인데, 주로 소개팅 앱에서 사용하는 방식의 UI이다. 뭔가 한 번 만들고 싶어서 이것도 만들 예정이다.

둘 다 ViewPager를 이용해서 구현했다고 생각이 들었고, Compose UI를 이용해 한번 만들어보도록 하자.

참고로 안드로이드 공식 문서인 ViewPager와 검색을 통해 Ui를 구현했다.


기본 베이스

나는 일단 서버와 연결한 것도 아니고, 이미지도 굳이 연결할 필요가 없을 것이라 생각해
대충 List를 이용해 색깔을 지정하고 배경색만 다르게 해서 위와 같은 UI를 구현하려고 한다.

@Composable
fun ViewPagerEx(name: String, modifier: Modifier = Modifier) {
	//이게 첫 번쨰 UI에 사용될 색상
    val firstBackgroundList = listOf(
        Color.Red,
        Color.Blue,
        Color.Cyan,
        Color.Gray,
        Color.Black,
        Color.DarkGray
    )

	//이게 두 번째 UI에 사용될 색상
    val secondBackgroundList = listOf(
        Color.Yellow,
        Color.Magenta,
        Color.LightGray,
        Color.DarkGray,
        Color.Black,
        Color.Cyan,
        Color.Red,
        Color.Blue
    )

    LazyColumn(
        modifier = modifier
            .fillMaxSize()
    ) {
        //첫 번째 UI
        item {
            
        }

        //두 번째 UI
        item {
              
        }
    }

}

위와 같이 기본적인 Composable에 두 UI를 각 item안에 넣어서 보여줄 예정이다.


첫 번째 UI 구현

1. 일반 인피니티 ViewPager 구현

일반적으로 ViewPager의 사이즈가 지정되면 끝에 이동하면 더 이상 스크롤이 되지 않는다.
하지만 대부분 이커머스 앱이나 내가 보여준 앱에서는 끝에 이동해도 다시 첫 번째 이미지로 돌아오도록 구현이 되어있을 것이다.

그래서 일단 인피니티 ViewPager를 간단하게 구현을 먼저 할 것이다.

@Composable
fun FirstUi(modifier: Modifier = Modifier, info: List<Color>) {
	//pager의 전체 사이즈를 최대 길이로 설정
    val infiniteCount = Int.MAX_VALUE
    
    //시작 인덱스를 중간부터 시작하지만 info의 첫 번째 부분부터 보여주기 위해 나먹지가 있으면 그만큼 왼쪽으로 이동
    val startIndex = (infiniteCount / 2) - ((infiniteCount / 2) % info.size)
    
    //pagerState 설정
    val pagerState = rememberPagerState(
        initialPage = startIndex,
    ) { infiniteCount }

    Box(modifier = modifier.fillMaxWidth()){
    
    	//이제 각 위치마다 보여주는 배경색 지정
        HorizontalPager(
            state = pagerState
        ) {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(400.dp)
            ) {
                Box(
                    modifier = modifier
                        .fillMaxSize()
                        .background(color = info[(it % info.size)])
                )
            }
        }
		
        //이것은 현재 페이지의 정보를 보여주도록 설정
        Text(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(8.dp),
            text = "${(pagerState.currentPage % info.size) + 1} / ${info.size}",
            fontWeight = FontWeight.Bold,
            fontSize = 24.sp
        )
    }
}

위 코드를 첫 번째 item에 넣고 실행하면 아래와 같이 동작이 된다.

@Composable
fun ViewPagerEx(name: String, modifier: Modifier = Modifier) {
	//...
    
    LazyColumn(
        modifier = modifier
            .fillMaxSize()
    ) {
        //첫 번째 UI
        item {
            FirstUi(info = firstBackgroundList)
        }

		//...
    }

}

캬 깔끔하게 동작이 된다. 다만 자동으로 넘기는 것이 아닌 직접 스와이프를 통해 이동을 한 것이다.

2. 특정 시간마다 다음 페이지로 이동하기

이제 직접 스와이프를 하는 것이 아닌 특정 시간마다 이동하는 방법을 알아보자.
따로 파라미터를 만들어서 변경 가능하게 동작하는 것도 좋지만 일단은 2초마다 다음 페이지로 이동하는 것을 구현하려고 한다.

이를 적용하기 위해서 LaunchedEffect를 적용했다.

@Composable
fun FirstUi(modifier: Modifier = Modifier, info: List<Color>) {
  	//...
  
  	//추가하기
    LaunchedEffect(pagerState.currentPage) {
        delay(2000L)
        pagerState.animateScrollToPage(pagerState.currentPage + 1)
    }
	
    Box(modifier = modifier.fillMaxWidth()){
        //뷰페이저 UI
    }
}

처음에 위와 같이 2초마다 다음 페이지로 이동하는 방식을 구현했었는데 아래와 같이 페이지가 이동하다가 중간에 멈춰버리는 문제가 발생했다.

이는 currentPage가 바뀔 때 마다 LaunchedEffect의 키값이 변경되어 다시 취소되고 실행되기 때문에 애니메이션이 동작하다가 멈춘거라 생각했다.

그래서 처음에 키값을 Unit으로 두고 while문을 이용해 이동을 진행했다. 이럴 경우 취소되고 다시 시작할 일이 없기 때문에 제대로 동작할 것이라고 생각이 들었다.

@Composable
fun FirstUi(modifier: Modifier = Modifier, info: List<Color>) {
    //기타 설정들...
	
    //키값을 Unit으로 두고 while문으로 2초마다 다음페이지 이동하기
    LaunchedEffect(Unit) {
        while (true) {
            delay(2000L)
            pagerState.animateScrollToPage(pagerState.currentPage + 1)
        }
    }

    Box(modifier = modifier.fillMaxWidth()) {
        //뷰페이저 UI
    }
}

위 코드로 변경되고 실행한 결과 아래와 같이 정상적으로 나오는 것을 볼 수 있다.

굳.. 사실 이것만 구현해도 인피니티 뷰를 보여주는 것이 거의 완성이 되었다.
하지만 다른 문제점이 있다. 다른 이커머스 앱은 해당 지점을 누르고 있으면 다른 화면으로 자동 스와이프가 되지 않고 정지되게 된다. 하지만 지금 구현으로는 아무리 누르고 있어도 다음 화면으로 이동하게 된다.

3. 누르고 있을 때 자동 화면이동 멈추게 만들기

@Composable
fun FirstUi(modifier: Modifier = Modifier, info: List<Color>) {
    //페이저를 눌렀을 때 정보 변경용
    var isPressed by remember {
        mutableStateOf(false)
    }

    LaunchedEffect(!isPressed) {
    	//눌려져있는 상태가 아닌 경우에만 애니메이션이 동작하는 방향으로 진행
        if(!isPressed) {
            while (true) {
                delay(2000L)
                pagerState.animateScrollToPage(pagerState.currentPage + 1)
            }
        }
    }

    Box(modifier = modifier.fillMaxWidth()) {
        HorizontalPager(
        	//페이저의 modifier를 이용해 포인터 입력을 받을 때마다 정보를 가져온다.
            //이 중 event.type이 눌렀을 때와 땟을 경우 isPressed 정보를 바꾼다.
            modifier = Modifier.pointerInput(Unit) {
                awaitPointerEventScope {
                    while (true) {
                        val event = awaitPointerEvent()
                        when (event.type) {
                            PointerEventType.Release -> {
                                isPressed = false
                            }

                            PointerEventType.Press -> {
                                isPressed = true
                            }
                        }
                    }
                }
            },
            state = pagerState,
        ) {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .background(color = info[(it % info.size)])
                    .height(400.dp)
            )
        }
    }
}

pointerInput은 사용할 때마다 참 어려운 것 같다. 찾아본 결과 공식 문서에 동작 이해하기를 통해 해당 정보를 받아오는 것을 알 수 있었다.
이 정보들을 토대로 눌려져있을 때는 자동 이동을 멈추고 뗏을 때 다시 동작하는 방향으로 진행했다.

아래는 전부 코드를 구현 했을 때 동작하는 결과다.

이것으로 무한 스크롤 기능은 어느 정도 구현이 완료되었다. 이제는 두 번째 Ui를 구현해보자.


두 번째 UI 구현

이번엔 두 번째 UI를 구현해보자.

1. 리스트 정보를 토대로 viewPager 구현

일단 첫 번째 UI를 시작했던 것처럼 기본 Pager를 구현해준다.

@Composable
fun SecondUi(modifier: Modifier = Modifier, info: List<Color>) {
    val pagerState = rememberPagerState(initialPage = 0) { info.size }

    HorizontalPager(
        modifier = modifier,
        state = pagerState,
    ) {
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .background(color = info[(it % info.size)])
                .height(400.dp)
        )
    }
}

2. 양 쪽 너비 맞추기

Box의 사이즈를 withd 200, height 400으로 설정하고 양 쪽 너비가 보이도록 설정해보자

@Composable
fun SecondUi(modifier: Modifier = Modifier, info: List<Color>) {
    val pagerState = rememberPagerState(
        initialPage = (info.size / 2),
    ) { info.size }

    val widthWeight = 0.5F
    val pageSpacing = 30.dp
    val configuration = LocalConfiguration.current
    val pageSize = PageSize.Fixed(pageSize = (configuration.screenWidthDp * widthWeight).dp)

    HorizontalPager(
        modifier = modifier.fillMaxWidth(),
        state = pagerState,
        pageSize = pageSize,
        contentPadding = PaddingValues(horizontal = (configuration.screenWidthDp.dp * (1f - widthWeight) / 2)),
        pageSpacing = pageSpacing
    ) { page ->
        Box(
            modifier = Modifier
                .width(200.dp)
                .height(400.dp)
                .clip(RoundedCornerShape(16.dp))
                .background(info[(page % info.size)])

        )
    }
}

보는 바와 같이 가운데 부터 시작해서 양쪽에 보이도록 구현이 되었다.

3. 회전시키기

회전하는 방법은 graphicsLayer을 이용해 회전을 진행했다.

modifier.graphicsLayer는 Jetpack Compose에서 UI 요소에 저수준의 그래픽 효과를 적용할 수 있게 해준다.
즉, 뷰의 회전, 크기 조절, 투명도, 위치 이동 등을 픽셀 단위로 미세하게 조절할 수 있는 레이어 변환 도구다.

아래와 같은 효과를 줄 때 주로 사용한다.

graphicLayer를 확인할 수 있는 공식 문서를 보고 참조하였으며, 회전에 대한 정보는 블로그를 이용해 참고했습니다.

단순히 회전을 하는것이 아닌 회전했을 때 y의 값의 위치를 바꾸어야 좀 더 자연스럽다는 것을 알았고 이를 참조하여 아래 코드를 작성

@Composable
fun SecondUi(modifier: Modifier = Modifier, info: List<Color>) {
    val pagerState = rememberPagerState(
        initialPage = (info.size / 2),
    ) { info.size }
    val rotateDegree = 15F
    val widthWeight = 0.5F
    val pageSpacing = 30.dp
    val configuration = LocalConfiguration.current
    val pageSize = PageSize.Fixed(pageSize = (configuration.screenWidthDp * widthWeight).dp)

    HorizontalPager(
        modifier = modifier.fillMaxWidth(),
        state = pagerState,
        pageSize = pageSize,
        contentPadding = PaddingValues(horizontal = (configuration.screenWidthDp.dp * (1f - widthWeight) / 2)),
        pageSpacing = pageSpacing
    ) { page ->
        Box(
            modifier = Modifier
                .graphicsLayer {
                    val pageOffset = ((pagerState.currentPage - page) + pagerState
                        .currentPageOffsetFraction
                            ).absoluteValue


                    rotationZ = when {
                        page < pagerState.currentPage -> -rotateDegree
                        page > pagerState.currentPage -> rotateDegree
                        else -> 0F
                    }

                    val distance = (configuration.screenWidthDp.dp / 2) - pageSpacing
                    val height = sin(Math.toRadians(rotateDegree.toDouble())) * distance.toPx()

                    translationY = (height * pageOffset.absoluteValue).toFloat()

                    alpha = lerp(
                        start = 0.8F,
                        stop = 1F,
                        fraction = 1F - pageOffset.absoluteValue.coerceIn(0F, 1F)
                    )
                }
                .width(200.dp)
                .height(400.dp)
                .clip(RoundedCornerShape(16.dp))
                .background(info[(page % info.size)])

        )
    }
}

lerp 함수를 이용하면 보간을 통해 오프셋 위치에 따른 투명도를 조절할 수 있다. 이 또한 공식 문서에서 사용하는 것을 그대로 참조하였다.


전체 코드를 보고 싶은 분은 아래 깃허브 저장소를 참고하면 될 것 같다. MainActivity 파일에 구현해놓았다.

profile
좋은 개발자가 되기까지

0개의 댓글