1. 들어가며
회사에서 Compose 마이그레이션을 반년째 진행중이다.
(오래걸리는 이유는 시간을 잡고 한번에 바꾸는 게 아니라, 매 스프린트 마다 새로 들어가는 UI 를 컴포즈로 만들거나, 새로 들어가는 기능을 담은 화면을 리빌딩하는 방식을 채택했기 때문이다.)
최근에 홈화면을 개편하고 많은 앱에 들어가는 무한롤링 배너도 컴포즈로 갈아끼우는 작업을 하였다.
그리고 컴포즈로 구현하는 것의 장점을 또 한번 느꼈고,
많은 앱에 들어가는 기능이기도 하니 구현한 내용을 행복했던 감정과 함께 글로 정리하려고 한다.
각설하고 바로 구현만 보고싶으시다면 4번부터 보면 된다.
*참고로 필자가 진행하는 컴포즈 버전은 1.4.2 이다.
아마 예에전 버전을 사용하시거나 accompanist 의 컴포넌트를 사용한다면 구현 내용이 조금 다를 수 있다.
크게 다른 점은 없을테니, 해당 컴포저블의 문서와 구현내용을 참고하면 좋겠다.
2. 간단한 무한 루프 요구사항 소개와 예시
- 페이지 형태로 스크롤이 가능하다. (pager)
- 양 끝에 도달하고 한번 더 같은 방향으로 스크롤 했을 때 반대방향 맨 끝 페이지가 한다. 반복된다. (infinite)
- N초마다 자동으로 페이지가 전환된다. (auto slide)
많은 앱이 사용하지만, 이해를 위해 하나 예를 들자면 쿠팡 홈화면의 배너가 있다.
예시로 만들어볼 결과물과 함께 첨부한다.
영상이 끊길 수도 있는데 코드를 실행해보면 부드럽게 잘 실행된다!
3. 기존 뷰에선 어떻게 만들었나?
3-A. 핵심 구현 아이디어.
사실 기존 뷰에서 어떻게 만들었는지 핵심 아이디어만 알고 있으면 Jetpack Compose 로 만드는 것은 식은 죽 먹기이다.
바로 Adapter 에서 오버라이드한 getItemCount 메소드에 리스트의 사이즈가 아닌 최대한 큰 값(Int 를 반환해야하니 Int.MAX_VALUE)을 주는 것이다.
그리고 나머지 연산자(%)를 이용해서 원하는 페이지의 인덱스로 이동시키거나 현재 페이지의 인덱스를 구하는 방식이 되겠다.
*물론 다른 방법을도 있지만 오늘은 쉽게 구현하는 것에 초점을 맞춘다.
3-B. 불만은 다음과 같다.
꽤 오래된 앱이 아니라면 ViewPager2 를 사용해서 구현했을 것이다.
열받는 점은 파일이 많고 분산되어 있다는 점이다. 그리고 Adapter 를 만들고, xml 에 ViewPager2 를 배치하고, ViewController 에서 바인딩이든 아이디로 inflate 된 ViewPager2 를 찾든, 찾아서 만들어둔 Adapter 를 연결하고 타이머를 돌리며 페이지를 바꿔줘야한다..
정리하면 내 불만 포인트는 아래 세 가지이다.
- 파일을 여러개 만들어야함
- 보일러플레이트 코드
- 상태 관리가 분산됨
셋 중 2, 3번은 다른 사람이 코드 이해하기 어렵게 만들어서도 좋지 않다고 생각한다.
이제 위 불만을 한번에 없애줄 Horizontal Pager 를 살펴보자.
4. Jetpack Compose 로 구현해보기
4-A. HorizontalPager 를 사용할 거예요.
Compose 의 Pager 로 구현할 것이다.
internal fun 인 Pager 를 직접 사용하는 것은 아니고 공개된 HorizontalPager, VerticalPager 중 하나를 사용할 것인데,
보통 배너는 횡스크롤 형태가 많으니 HorizontalPager 를 사용한다.
Compose 의 Pager 는 기존 뷰 구성 방식에서 ViewPager2 라는 컴포넌트에 대응한다.
ViewPager2 가 RecyclerView 를 기반으로 만들어졌 듯
Pager 도 구현 내용을 살펴보면 LazyColumn 같은 것을 만들 때 사용되는 LazyList 라는 컴포저블을 이용하여 만들어진다는 것을 알 수 있다.
우선 한 페이지씩 filingBehavior 에 의해 제공되는 snap 애니메이션이 가능한 LazyList 쯤으로 보면 된다.
LazyList 를 이용하므로 Pager 역시 화면에서 떨어져나간 것을 그리지 않는다.
4-B. 사용방법은 공식문서에서 확인.
사용방법 자체는 공식문서에 가장 잘 나와있다.
글 내용은 무한 롤링 배너를 만들기 위해 어떻게 "활용" 했는지이다.
*물론 공식문서에서도 활용 예시가 많이 나온다. contentPadding 을 줘서 다음 페이지를 미리 노출시키는 기능
PageSize 를 설정한다든지, 스크롤 offset 에 따라서 graphicsLayer 에서 효과를 주는 것들이 나온다.
4-C. 구현 (무한루프에만 집중)
주석대신 읽기쉬운 코드 만들자는 스타일인데, 전달하기는 주석만한 게 없는 것 같아서 코드에 주석을 달았다.
먼저 딱 요구사항에 핏한 코드만 보자면 아래와 같다.
실제 프로젝트에서는 이거저거 파라미터나 추가 UI 구현 코드가 덕지덕지 붙겠지만 그냥 프리뷰로 아래 컴포저블 함수만 호출하면 실행되게 만들었다.
(하드 코딩된 값들도 예시일 뿐이니 양해부탁드린다.)
const val AUTO_PAGE_CHANGE_DELAY = 1000L
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun InfiniteLoopPager(
modifier: Modifier = Modifier,
list: List<Color> = listOf(
Color.Red,
Color.Yellow,
Color.Green,
Color.Blue,
Color.Magenta,
Color.Cyan
)
) {
val pagerState = rememberPagerState()
// 초기페이지 설정. 한번만 실행되기 원하니 key 는 Unit|true.
LaunchedEffect(key1 = Unit) {
// 최대한 많은 페이지 양쪽으로 보여주기 위함.
var initialPage = Int.MAX_VALUE / 2
// 초기페이지를 0으로 잡는다.
while (initialPage % list.size != 0) {
initialPage++
}
pagerState.scrollToPage(initialPage)
}
// 지정한 시간마다 auto scroll.
// 유저의 스크롤해서 페이지가 바뀐경우 다시 실행시키고 싶기 때문에 key는 currentPage.
LaunchedEffect(key1 = pagerState.currentPage) {
launch {
while (true) {
delay(AUTO_PAGE_CHANGE_DELAY)
// 페이지 바뀌었다고 애니메이션이 멈추면 어색하니 NonCancellable
withContext(NonCancellable) {
// 일어날린 없지만 유저가 약 10억번 스크롤할지 몰라.. 하는 사람을 위해..
if (pagerState.currentPage + 1 in 0..Int.MAX_VALUE) {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
}
}
Box(modifier = modifier) {
HorizontalPager(
pageCount = Int.MAX_VALUE,
modifier = Modifier.aspectRatio(1f), // PageSize.Fill 상태에서 비율만 줘보기.
state = pagerState,
) { index ->
// index % (list.size) 나머지 값으로 인덱스 가져오기. 안전하게 getOrNull 처리.
list.getOrNull(index % (list.size))?.let { color ->
Spacer(
modifier = Modifier
.fillMaxSize()
.background(color = color)
)
}
}
}
}
@Preview
@Composable
fun InfiniteLoopPagerPreview() {
InfiniteLoopPager()
}
4-D. 구현 (인디케이터 추가)
먼저 인디케이터 코드는 아래와 같고, 예시로 대충 만든 거라 구린 함수 시그니처는 양해 부탁드린다.
@Composable
fun PagerIndicator(
modifier: Modifier = Modifier,
count: Int,
dotSize: Dp,
spacedBy: Dp,
currentPage: Int,
selectedColor: Color,
unSelectedColor: Color
) {
Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(spacedBy)) {
(0 until count).forEach { index ->
Box(
modifier = Modifier
.size(dotSize)
.background(
color = if (index == currentPage) {
selectedColor
} else {
unSelectedColor
},
shape = CircleShape
)
)
}
}
}
4-C 에서 HorizontalPager 를 Box 로 감싼 이유가 있었다.
바로 HorizontalPager 위에 인디케이터를 얹기 위함!
위 PagerIndicator 함수를 HorizontalPager 의 부모 컴포저블인 Box 에 적절히 배치하면 된다.
const val AUTO_PAGE_CHANGE_DELAY = 1000L
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun InfiniteLoopPager(
modifier: Modifier = Modifier,
list: List<Color> = listOf(
Color.Red,
Color.Yellow,
Color.Green,
Color.Blue,
Color.Magenta,
Color.Cyan
)
) {
val pagerState = rememberPagerState()
// 초기페이지 설정. 한번만 실행되기 원하니 key 는 Unit|true.
LaunchedEffect(key1 = Unit) {
// 최대한 많은 페이지 양쪽으로 보여주기 위함.
var initialPage = Int.MAX_VALUE / 2
// 초기페이지를 0으로 잡는다.
while (initialPage % list.size != 0) {
initialPage++
}
pagerState.scrollToPage(initialPage)
}
// 지정한 시간마다 auto scroll.
// 유저의 스크롤해서 페이지가 바뀐경우 다시 실행시키고 싶기 때문에 key는 currentPage.
LaunchedEffect(key1 = pagerState.currentPage) {
launch {
while (true) {
delay(AUTO_PAGE_CHANGE_DELAY)
// 페이지 바뀌었다고 애니메이션이 멈추면 어색하니 NonCancellable
withContext(NonCancellable) {
// 일어날린 없지만 유저가 약 10억번 스크롤할지 몰라.. 하는 사람을 위해..
if (pagerState.currentPage + 1 in 0..Int.MAX_VALUE) {
pagerState.animateScrollToPage(pagerState.currentPage + 1)
}
}
}
}
}
Box(modifier = modifier) {
HorizontalPager(
pageCount = Int.MAX_VALUE,
modifier = Modifier.aspectRatio(1f), // PageSize.Fill 상태에서 비율만 줘보기.
state = pagerState,
) { index ->
// index % (list.size) 나머지 값으로 인덱스 가져오기. 안전하게 getOrNull 처리.
list.getOrNull(index % (list.size))?.let { color ->
Spacer(
modifier = Modifier
.fillMaxSize()
.background(color = color)
)
}
}
// 추가됨
PagerIndicator(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(start = 16.dp, bottom = 16.dp),
count = list.size,
dotSize = 6.dp,
spacedBy = 4.dp,
currentPage = pagerState.currentPage % list.size,
selectedColor = Color.White,
unSelectedColor = Color.LightGray
)
}
}
4-E. 기존 뷰 구성방식으로 구현할 때 느꼈던 불만이 해결되었나?
3-B 의 불만을 다시 가져오면
- 파일을 여러개 만들어야함 -> 그럴 필요 없어짐.
- 보일러플레이트 코드 -> 최소화됨. -> 코드 양이 압도적으로 적음. 린트 지키며 작성해도 30~40줄이면 무한배너 로직 구현 뚝딱
- 상태 관리가 분산됨 -> 한 곳에서 관리됨.
편-안.
5. 마무리 느낀점.
작년 9월 즈음부터 컴포즈로 개인프로젝트부터 시작해서 6개월째 정도 실무에도 사용하면서 이거저거 구현해본바로 느끼는점은
컴포즈는 익숙해지기만 한다면 정말 빠르게 개발할 수 있겠다는 것이다.
이유를 예를 들자면 LazyList 류 컴포저블과 오늘 소개한 Pager 같은 경우 애초에 코드 양이 압도적으로 줄고 비교적 한 곳에서 작업하게 되었기 때문이라고 생각한다.
이외에도 재사용성이 용이한 점도 그렇고 나열하면 많겠지만.. 주제에 벗어나니 생략.
하지만 아직 내가 익숙하지 않은 부분도 있다.(ex. 기존 CoordinatorLayout 대체),
그리고 아름다운 퍼포먼스와 원하는 결과물을 적은 빌드 횟수로 만들기 위해, 내부 로직을 뜯어보기도 해보고 싶다.
현재도 실무에 필요하다고 판단될 때 과감히 뜯어보고 있었고, 그 기조를 유지해왔지만,
최근에 찰스의 안드로이드 컨퍼런스에 다녀왔는데 런타임을 뜯어보신 분을 보고 생각이 좀 바뀐 것 같다.
리컴포지션의 내부 원리까지 파악한 사람이 구현하는 결과물은 성능이 좋고 시도 횟수가 낮지 않을까? (주니어 개발자의 환상일수도..?, 전 지금 2년차에요)
천천히 호기심 가는대로 공부도 해봐야겠다.