원티드 앱에 Compose 적용해보기 — 3

Nampyo Jeong
원티드랩 기술 블로그
12 min readNov 3, 2021

이번 글에서는 커뮤니티에서 글 목록 화면을 LazyColumn을 사용해 구현하는 내용을 다뤄보겠습니다.

GlideImage

먼저 게시글 항목의 UI를 구성해보겠습니다. 기본 구성은 간단하므로 생략하고 하단 회색으로 되어 있는 유저 정보 영역만 살펴보겠습니다.

주요 구성을 살펴보면 아바타 표시부분, 유저 이름과 시간 표시 부분(동적으로 width가 변경되어야 함), 좋아요/댓글 수 표시 부분으로 나눠집니다.

아바타를 표시할 때는 이미지 로더가 필수인데 아직은 각각의 이미지 로더가 compose를 지원하진 않기 때문에 별도 구성이 필요합니다. 다행히 잘 만들어진 오픈소스가 있어서 손쉽게 구성할 수 있었습니다. (link)

원티드 앱에서는 Glide를 사용하고 있기 때문에 동일한 스펙으로 맞추는 게 좋을 것 같아 GlideImage를 선택했습니다.

@Composable
private fun PostInfoContainer(
useCase: CommunityPostUseCase
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(size = 10.dp))
.background(color = colorResource(
id = R.color.neutral_gray_100
))
.padding(horizontal = 16.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GlideImage(
modifier = Modifier
.size(size = 28.dp)
.align(alignment = Alignment.CenterStart),
imageModel = url,
requestOptions = RequestOptions.circleCropTransform(),
)

...
}
}

Row에서 각 항목에 weight 설정하기

이어서 살펴볼 부분은 유저 이름 표시 부분입니다.

이 부분은 좌우에 다른 뷰가 자리잡고 남은 영역 안에서 유저 이름과 시간이 표시되어야 합니다. 이때 만약 이름이 길면 시간은 그대로 다 표시되어야 하고 이름이 잘려져야 하기 때문에 Row로 한 번 더 감싸서 weight로 구성했습니다.

@Composable
private fun PostInfoContainer(
useCase: CommunityPostUseCase
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(size = 10.dp))
.background(color = colorResource(
id = R.color.neutral_gray_100
))
.padding(horizontal = 16.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
GlideImage(
modifier = Modifier
.size(size = 28.dp)
.align(alignment = Alignment.CenterStart),
imageModel = url,
requestOptions = RequestOptions.circleCropTransform(),
)
Spacer(modifier = Modifier.width(4.dp))
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
modifier = Modifier.weight(1f, fill = false),
text = useCase.writerName,
style = TextStyle(
color = colorResource(
id = R.color.neutral_black_100
),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
),
maxLines = 1,
)
Spacer(modifier = Modifier.width(6.dp))
Box(
modifier = Modifier
.size(size = 2.dp)
.clip(RoundedCornerShape(size = 1.dp))
.background(color = colorResource(
id = R.color.neutral_gray_500
))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = timeText,
style = TextStyle(
color = colorResource(
id = R.color.neutral_gray_800
),
fontSize = 12.sp,
),
maxLines = 1,
)
}
...
}
}

추가로 살펴볼 부분은 유저 이름을 표시하는 Text에서 weight(1f, fill = false) 부분입니다. 유저 이름 Text에 weight를 설정하지 않으면 이름이 긴 경우 시간 Text가 잘려지게 되고 fill = false를 지정하지 않으면 유저 이름 Text가 wrap_content로 동작하지 않습니다.(fill의 기본 값은 true입니다)

LazyColumn

이제 LazyColumn을 구성해보겠습니다.

@Composable
private fun PostList(
posts: List<CommunityPostUseCase>,
) {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(
top = 8.dp,
bottom = 80.dp,
),
) {
items(posts) {
PostItem(useCase = it)
Divider(
modifier = Modifier.padding(
start = 13.dp,
end = 12.dp,
),
color = colorResource(
id = R.color.neutral_gray_200
),
)
}
}
}

네. 이게 전부입니다.

contentPadding과 Divider 내용을 제외하고 보면 LazyColumn과 items 구성이 전부입니다. 기존 UI에서 RecyclerAdapter와 ViewHolder, xml을 구성하는 방식에 비하면 매우 적은 양의 코드로 구현할 수 있고, items 여러개를 구성할 수도 있어서 ConcatAdapter도 필요없어 아주 간결합니다.

필요에 따라 items(count = n)으로 할 수도 있고 itemsIndexed()도 사용할 수 있습니다.

InfiniteScroll — Paging 처리

Compose에서 페이징 처리하는 방법을 구글링해보면 대부분 나오는 정보는 Jetpack Paging library를 사용하는 방식이거나 itemsIndexed를 사용해서 마지막 index가 그려질 때 다음 페이지를 호출하는 방식이 검색됩니다.

하지만 저는 기존 원티드앱에서 사용하고 있는 EndlessScrollListener와 유사한 방식으로 스크롤이 하단에 도달하기 전에 미리 다음 페이지를 로드하고 중복 로드를 방지하는 방식을 사용하고 싶었고 적절한 코드를 찾을 수 있었습니다. (link)

위 링크의 코드를 활용해서 InfiniteListHander를 적용한 코드입니다.

@Composable
private fun PostList(
posts: List<CommunityPostUseCase>,
onLoadMore: () -> Unit,
) {
val listState = rememberLazyListState()
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = listState,
contentPadding = PaddingValues(top = 8.dp, bottom = 80.dp),
) {
...
}

InfiniteListHandler(
listState = listState,
onLoadMore = onLoadMore,
)

}
@Composable
fun InfiniteListHandler(
listState: LazyListState,
buffer: Int = 2,
onLoadMore: () -> Unit,
) {
val loadMore = remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItemsNumber = layoutInfo.totalItemsCount
val lastVisibleItemIndex = (layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0) + 1

lastVisibleItemIndex > (totalItemsNumber - buffer)
}
}

LaunchedEffect(loadMore) {
snapshotFlow { loadMore.value }
.distinctUntilChanged()
.collect {
onLoadMore()
}
}
}

SwipeRefreshLayout

마지막으로 SwipeRefresh를 적용했습니다. SwipeRefreshLayout 역시 아직은 공식적으로 지원하는 컴포넌트가 아니어서 구글에서 만든 accompanist 라이브러리를 사용했습니다. (link)

@Composable
internal fun CommunityBoard(
viewModel: CommunityBoardViewModel
) {
val posts by viewModel.posts.observeAsState(listOf())
val isLoading by viewModel.isLoading.observeAsState(true)

SwipeRefresh(
state = rememberSwipeRefreshState(isLoading),
onRefresh = viewModel::refresh,
indicator = { state, trigger ->
WantedSwipeRefreshIndicator(state, trigger)
},
) {
PostList(
posts = posts,
onLoadMore = viewModel::loadMore,
)
}
}

accompanist에는 SwipeRefreshLayout 외에도 ViewPager, Insets, AppcompatTheme 등 유용한 라이브러리들을 제공하고 있으니 참고하시면 좋을 것 같습니다.

+) LazyColumn 성능 이슈(?)

LazyColumn을 구현하고 debug 모드로 실행해보면 스크롤이 스무스하게 움직이지 않고 뻑뻑(?)하게 느껴집니다. 이미지 로드가 동기로 처리되고 있어서 실제로 성능이 안 좋은 경우도 있을 수 있지만 대부분은 debug모드에서만 나타나는 현상으로 release 모드로 실행해보면 정상적으로 우수한 퍼포먼스를 발휘합니다. (참고링크)

마무리

이번 글에서는 앱 개발에서 가장 많이 사용되는 ListView를 Compose로 만드는 방법을 알아봤습니다.
아직 지원되지 않는 컴포넌트도 많아서 다양하게 활용되기는 이른 것 같지만 커뮤니티 게시글 목록 화면처럼 단순 목록 표시하기엔 충분한 걸로 보입니다.
무엇보다 리스트를 구성할 때 Adapter, ViewHolder, xml까지 일일이 만들지 않고 훨씬 간결하게 만들 수 있어서 개인적으로는 매우 만족스러웠습니다.

다음 글은 이번에 만든 세로 리스트에 가로 스크롤 섹션을 추가하는 내용으로 작성해보겠습니다. 감사합니다.

원티드에서는 다양한 직군에서 적극적으로 채용중입니다! 서버, 웹, 앱, 디자인 등 제품을 만들어가는 각자의 분야에서 전문적인 분들과 함께 일하기를 기대하고 있습니다. 회사 채용 정보 페이지를 확인해 주세요!

--

--