Android Compose HorizontalPager Animations(w. 불티)

Mangbaam
11 min readMay 6, 2024

--

이런 뷰를 만들어봅시다

공연 예매 서비스 불티를 개발하며 위와 같이 동작하는 티켓 목록을 개발하였습니다.

불티 뿐만아니라 회사 앱에서도 위와 비슷한 뷰를 작업한 경험이 있어 HorizontalPager를 사용해 UI, 애니메이션 요구사항을 충족해나가는 과정을 소개하고자 합니다.

양 옆에 살짝 튀어나오게 만들기

val pagerState = rememberPagerState(
pageCount = { uiState.tickets.size }
)

HorizontalPager(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
state = pagerState,
key = { uiState.tickets[it].ticketId },
) { page ->
val ticket = uiState.tickets[page]
Ticket(ticket = ticket)
}

HorizontalPager 의 content는 기본적으로 HorizontalPager 를 가득 채웁니다. 여기서 양 옆에 옆의 티켓을 표시하기 위해서는 어느 정도 공간이 띄워져야 하고, 옆 티켓과 얼마나 떨어져 있을 지도 정의되어야 합니다.

다행히도 HorizontalPagercontentPaddingpageSpacing 속성을 제공하여 간단하게 구현할 수 있습니다.

val contentPadding = 30.dp
val pageSpacing = 16.dp

HorizontalPager(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
state = pagerState,
key = { uiState.tickets[it].ticketId },
contentPadding = PaddingValues(horizontal = contentPadding),
pageSpacing = pageSpacing,
) { page ->
val ticket = uiState.tickets[page]
Ticket(ticket = ticket)
}
  • contentPadding은 content 에 얼만큼의 패딩을 줄 지 결정하는 속성
  • pageSpacing은 페이지 사이 간격.

위 구현에서는 contentPadding에 horizontal = 30dp, pageSpacing에 16dp를 주어서 양 옆에 티켓이 표시될 수 있었습니다.

투명도 조절하기

HorizontalPager(
// HorizontalPager 속성들
) { page ->
val ticket = uiState.tickets[page]
Ticket(
modifier = Modifier
.graphicsLayer {
val pageOffset =
((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
alpha = lerp(
start = 0.2f,
stop = 1f,
fraction = 1f - pageOffset.absoluteValue.coerceIn(0f, 1f),
)
},
ticket = ticket,
)
}

pageOffset 구하기

투명도를 페이지 이동에 따라 자연스럽게 변경하려면 pageOffset을 구해야합니다.

pageOffset은 현재 페이지가 정지할 위치에서 얼만큼 떨어져있는지 나타내는 수치(Float)입니다.

다음 식으로 구할 수 있습니다.

val pageOffset = 
pagerState.currentPage - page + pagerState.currentPageOffsetFraction

PagerStatecurrentPage는 현재 포커즈 된 (손을 놓으면 가운데로 스냅될) 페이지이고, currentPageOffsetFraction은 현재 페이지의 fraction 입니다.

fraction은 페이지가 스냅되어 정지한 상태에서 0이며, 왼쪽으로 절반이 넘어가면 -0.5, 오른쪽으로 절반이 넘어가면 0.5가 되는 -0.5 ~ 0.5 사이의 값입니다.

즉, 각 페이지마다 pageOffsetFraction을 구하려면 현재 페이지에서 얼마나 떨어져 있는 페이지인지 구하고(pagerState.currentPage — page) 거기에 현재 페이지의 fraction(pagerState.currentPageOffsetfraction)을 더하면 위와 같은 식이 도출됩니다.

alpha 값 변경하기

이제 티켓의 투명도를 변경하기 위해서 ModifiergraphicsLayer 블록에서 alpha 속성을 변경해주면 됩니다.

여기에는 lerp라는 함수를 사용했는데, start부터 stop 까지 fraction의 값에 따라 선형적으로 값이 변화하는 함수입니다.

의존성 추가는 여기를 참고하세요.

alpha = lerp(
start = 0.2f,
stop = 1f,
fraction = 1f - pageOffset.absoluteValue.coerceIn(0f, 1f),
)

위 코드에서는 투명도가 0.2 ~ 1 까지 pageOffset에 따라 변경되는 동작을 하게 됩니다.

위치 내리기

불티의 요구사항은 아니었지만 회사 앱의 UI 요구사항이어서 함께 소개하고자 합니다.

방식은 위에서 투명도를 조정하던 것과 동일합니다. Modifier.graphicsLayer 블록에 다음 코드를 작성하면 됩니다.

// Modifier.graphicsLayer 블록에 정의
translationY = lerp(
start = 0.dp.toPx(),
stop = 20.dp.toPx(),
fraction = pageOffset.absoluteValue.coerceIn(0f, 1f),
)

높이 변경하기

높이는 scaleY 속성을 사용하여 변경할 수 있습니다.

val scaleSizeRatio = 0.8f

// Modifier.graphicsLayer 블록에 정의
scaleY = lerp(
start = 1f,
stop = scaleSizeRatio,
fraction = pageOffset.absoluteValue.coerceIn(0f, 1f),
)

요구사항을 충족한 것 같지만 버그드로이드를 자세히보면 옆으로 이동할 때 좌우로 늘어지는 것을 볼 수 있습니다. 너비는 유지한 채 높이만 변경했기 때문입니다.

그럼 다음과 같이 높이도 함께 변경해주면 어떻게 될까요?

val scaleSizeRatio = 0.8f

// Modifier.graphicsLayer 블록에 정의
lerp(
start = 1f,
stop = scaleSizeRatio,
fraction = pageOffset.absoluteValue.coerceIn(0f, 1f),
).let {
scaleX = it
scaleY = it
}

양 옆에 살짝 보이던 티켓이 이제는 보이지 않습니다.

https://developer.android.com/develop/ui/compose/graphics/draw/modifiers?hl=ko#graphics-modifiers

graphicsLayer에 대한 설명을 보면 그리기 단계에만 영향을 미친다고 되어있습니다.

즉, 위의 현상은 양 옆의 티켓 크기는 기존과 동일하지만 그리는 과정에서 scale 값이 조정된 크기로 그렸기 때문에 실제로는 보이지 않게 된 것입니다. (로그를 찍어보면 scale 값이 변경되기 전과 후의 size.width 값이 동일한 것을 확인할 수 있습니다)

다시 양 옆의 티켓이 살짝 보이게 하려면 조금 옮겨주어야 합니다.

좌우로 옮기기

그림으로 현재 상황을 이해해보겠습니다.

scale을 적용하지 않은 상태

scale을 적용하지 않은 상태에서는 티켓(핑크색)이 위와 같이 위치하고 있습니다.

scale이 적용된 상태

그러나 scale 0.8을 적용하면 옆의 티켓 영역(회색)은 여전히 동일하게 잡혀있지만 티켓(핑크색) 자체는 80% 작게 그려져 실제 화면(가운데 영역)에는 보이지 않게 됩니다.

좌우로 옮긴 상태

이를 요구 사항대로 구현하려면 왼쪽에 위치한 티켓은 오른쪽으로, 오른쪽에 위치한 티켓은 왼쪽으로 이동시켜줘야 합니다.

2가지를 고려해야 합니다.

  • 방향
  • 이동 거리

방향은 위에서 구한 pageOffset을 사용할 수 있습니다. pageOffset이 양수이면 현재 페이지보다 왼쪽에 있는 것이기 때문에 오른쪽으로 이동, 음수이면 왼쪽으로 이동해야 합니다.

val sign = if (pageOffset > 0) 1 else -1 이렇게 구할 수 있겠네요.

이동 거리는 원래 너비(w)에서 scale이 적용된 크기(w * s)를 뺀 값에서 2를 나눠주면 됩니다. 이 식을 조금 정리하면 다음과 같이 구할 수 있습니다.

val distance = w * (1 - s) / 2

이 둘을 곱하면 실제로 이동해야 하는 거리가 나오고, 이 값을 translationX로 설정하면 됩니다.

// Modifier.graphicsLayer 블록에 정의
lerp(
start = 1f,
stop = scaleSizeRatio,
fraction = pageOffset.absoluteValue.coerceIn(0f, 1f),
).let {
scaleX = it
scaleY = it
val sign = if (pageOffset > 0) 1 else -1
translationX = sign * size.width * (1 - it) / 2
}
완성된 UI

이렇게 alpha, scaleX, scaleY, translationX 까지 적용하면 위와 같은 UI를 구현할 수 있습니다.

전체 코드

val pagerState = rememberPagerState(
pageCount = { uiState.tickets.size }
)
val contentPadding = 30.dp
val pageSpacing = 16.dp
val scaleSizeRatio = 0.8f

HorizontalPager(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally),
state = pagerState,
key = { uiState.tickets[it].ticketId },
contentPadding = PaddingValues(horizontal = contentPadding),
pageSpacing = pageSpacing,
) { page ->
val ticket = uiState.tickets[page]
Ticket(
modifier = Modifier
.graphicsLayer {
val pageOffset = pagerState.currentPage - page + pagerState.currentPageOffsetFraction
alpha = lerp(
start = 0.5f,
stop = 1f,
fraction = 1f - pageOffset.absoluteValue.coerceIn(0f, 1f),
)
lerp(
start = 1f,
stop = scaleSizeRatio,
fraction = pageOffset.absoluteValue.coerceIn(0f, 1f),
).let {
scaleX = it
scaleY = it
val sign = if (pageOffset > 0) 1 else -1
translationX = sign * size.width * (1 - it) / 2
}
},
ticket = ticket,
)
}

--

--