반응형
기존 앱에 Jetpack Compose 도입기: LazyColumn 활용
배경
- 지난 7월말 Google I/O Extended Seoul 컨퍼런스를 다녀오고, 여러 자료들을 보면서 더이상 Compose에 대한 공부를 늦추면 안되겠다는 생각을 많이하게 되었다.
- 특히, 최근 업데이트 과정에서 Compose에 대한 지원이 아낌없이 이루어지고 있는 점, Figma와 같은 협업 툴에서도 관련 내용들이 속속들이 등장하고 있는 모습을 보면서, XML은 여전히 많이 사용되고있지만 점차 저물고 있는 해라는 생각을 많이 하게되었고 언제까지 XML만 사용할 수 있을까? 하는 의문이 많이 들었다.
- 따라서, 현재 진행하고 있는 프로젝트에서 새롭게 화면을 만들어야 하는 부분을 Compose로 개발하기로 확정짓고 개발하게 되었다.
- Compose로 처음 개발하는 만큼, 안드로이드를 처음 배웠을 때가 오버랩되면서 만족스러운 코드가 나왔다고는 할 수 없지만, Compose를 처음 접하는 입장에서 개발한 과정을 공유하면 좋을 것 같다는 생각이 들어 작성하게 되었다.
개발 기능
- Compose로 처음 개발하는 만큼, 다양한 기능이 존재하는 화면보다는 단순한 화면에서부터 적용시키는 것이 사이드 이펙트가 적을것이라고 판단하였고 사이드 이펙트가 적은 화면을 개발 대상 기준으로 하였다.
- 마침, 개발하고 있는 앱이 좋아요 기능이 존재하는데 좋아요를 누른 사람의 리스트가 보여지는 화면을 만들어야 하는 상황이라 해당 화면을 Compose로 만들어보기로 했다. 좋아요 리스트를 보여주는 것이외에는 많은 동작이 없는 화면이라 적합하다고 판단하였다.
물론, XML로 만들어진 레이아웃에도 ComposeView를 이용해 Compose로 개발할 수 있지만, 새 화면에서 Compose로 개발을 진행하면, 좀 더 Compose에 대해 잘 사용할 수 있을 것 같아 새로운 화면을 선택하였다.
기본 세팅
- Compose를 통해 개발하기 위해서는 개발에 앞서 여러 세팅들을 요구 한다.
- 대부분 공식문서에 있는대로 차근차근 따라가면 된다.
물론, 버전업을 하면서 다른 의존성과의 호환성 문제가 있을 수 있다.build.gradle (app 수준)
- Compose 버전 설정시 현재 Kotlin Version과 Compose Compiler Version의 호환성 체크
android { buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "본인의 Compose Version" } }
- 이후 원하는 의존성 선택적으로 추가
dependencies { val composeBom = platform("androidx.compose:compose-bom:2023.08.00") implementation(composeBom) androidTestImplementation(composeBom) ... }
- Compose 버전 설정시 현재 Kotlin Version과 Compose Compiler Version의 호환성 체크
기존 앱에 Compose를 도입하기 위한 구현 순서
- 기존 앱은
하나의 Activity
에여러 개의 Fragment
을Navigation Component
를 이용해 각 화면을 구성하고 있던 상태였고, 이를 고려했어야 했다. - 구현해야 하는 주요 부분은 크게 상단 액션바와, 그 아래 유저에 대한 정보가 담긴 컴포넌트가 반복되는 레이아웃이라고 볼 수 있다.
- 액션바와
- 유저 정보가 담긴 컴포넌트에 대한 레이아웃을 만든뒤
- 이를 서버에서 받아온 정보를 활용해 유저에게 리스트뷰 형식으로 보여주면 된다.
Implementation(구현)
Fragment
- 기존에 Fragment와 Navigation Component를 활용해, 앱을 구성하고 있던 상황이었기 때문에, 기존 Fragment를 활용하는 방식을 유지하되 Compose를 도입하는 방향으로 생각하게 되었고, 공식문서에서도 이러한 방법을 안내하고 있어 동일하게 진행하였다.
- Compose 공식문서 중 Migration Strategy의 Build new feature with Compose - New Screens 참고해 새로운 화면을 만들었다.
- → 이를 활용하면, XML없이도 Compose를 이용해 기존 앱에서 레이아웃을 구현할 수 있다.
class PostLikeUsersFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme {
Scaffold(topBar = { SetPostLikeUsersActionbar() }) { contentPadding ->
Box(modifier = Modifier.padding(contentPadding)) {
SetPostLikeUsers()
}
}
}
}
}
}
}
setViewCompositionStrategy
는Composition
을Dispose
해야하는 시기를 정의하는 역할을 한다.
기본적으로 설정되어 있는DisposeOnDetachedFromWindowOrReleasedFromPool
는View
가Window
으로부터detach
될 때마다Composition
을 삭제한다.
(RecyclerView
와 같이pooling container
의 일부가 아닌 경우에)
따라서,ComposeView
가Fragment View
에 있을 떄 사용하는DisposeOnViewTreeLifecycleDestroyed
로 변경해 설정한다.- 전적으로
Compose
에 기반하는 새로운 화면을 생성하기 위해서 XML 파일을 사용하는 대신setContent()
메서드를 호출하고, 이 메서드 내에서 레이아웃을 정의한다.- 따라서, 이 메서드 내에서 주요 UI를 구현하는
Action bar 구현 메서드
와유저 리스트를 구성하는 메서드
를 호출한다.
- 따라서, 이 메서드 내에서 주요 UI를 구현하는
- 가장 기본적인 Compsable UI를 작성하는 방식은
Column
,Row
를 사용하는데,LinearLayout
과 유사하다고 보면 될 것 같다.- 다만 여기선 유저 리스트 1개만 존재하므로
Box
로 구성하였다.
- 다만 여기선 유저 리스트 1개만 존재하므로
Scaffold
는 Material design의 기본 layout 구조를 만들기 위한 상위 레벨의 slots를 제공한다.TopAppBar
,BottomAppBar
,FloatingActionButton
,Drawer
의 slot을 제공해 쉽게 구현할 수 있도록 해주며, 이 부분들을 채워 넣으면서 알맞은 위치에 components를 배치하고 동작하도록 할 수 있다.- 여기서 특이한 점은
contentPadding
부분인데,Scaffold
의content에 해당하는 람다
에서다른 Composable 객체
를 추가할때 처럼 추가하면 영역이 잘리기 때문에,Padding
값을TopBar
와BottomBar
가 차지하는 영역의 크기에 해당하는contentPadding
값을 추가해서 정해줘야 하며, 정해주지 않으면 IDE에서도 경고한다.
- 여기서 특이한 점은
MaterialTheme
는 구성 가능한 함수의 스타일을 지정하는 방법이다.Theme
를 지정하는 경우에setContent
에서Theme
로 감싸주는게 일반적인 형태라고 한다.
Custom Action Bar
Action bar Title 설정
- 앱의
Custom Actionbar
에서 구현해야 하는 부분은 2가지 이다.- 뒤로가기 버튼
- 타이틀 텍스트
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SetPostLikeUsersActionbar() {
val interactionSource = remember { MutableInteractionSource() }
CenterAlignedTopAppBar(
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = colorResource(id = R.color.white_FFFFFF)
),
title = {
Text(
text = stringResource(id = R.string.post_like_users_title),
style = TextStyle(
fontSize = 16.sp,
lineHeight = 24.sp,
fontWeight = FontWeight(600),
color = colorResource(id = R.color.gray_1_313131),
textAlign = TextAlign.Center
)
)
},
navigationIcon = {
IconButton(
onClick = { findNavController().navigateUp() },
) {
Icon(
painter = painterResource(id = R.drawable.ic_back_sign),
contentDescription = "back sign",
)
}
}
)
}
- Scaffold는 Material3에서 제공하는 요소이고, 모든 Material3 부분은 아직 Experimental에 해당하기 때문에
@OptIn(ExperimentalMaterial3Api::class)
Annotation을 추가시켜야 한다. - 여기서 Title은 상단바에서 한가운데 위치해야하기 때문에
CenterAlignedTopAppBar
를 활용해 구현해주었다. - 뒤로가기의 경우
navigationIcon parameter
에 람다로 넣어주면 된다. 이때IconButton
을 활용해주면 된다. - Compose에서도 XML에서와 마찬가지로
리소스
를 가져와 적용할 수 있다.stringResource
,colorResource
,paintingResource
등을 이용해 리소스를 가져와 적용 시킬수 있고,resource id
를 작성해주면 된다.Context
를 가져올 수도 있지만, 굳이 사용할 이유는 없어보인다.
- 기존
Android View 시스템의 ImageButton
은Compose에서 IconButton
과 매칭되기 때문에, 뒤로가기 버튼의 경우Icon Button
으로 작성해준다. Button
과의 차이점은IconButton
은 내부에 보통Icon
을 포함하고,Button
은 내부에 보통Text
를 포함한다는 점이다.IconButton
의parameter
중 클릭시 어떤 동작을 할지 설정하는onClick
과IconButton에 그려질 Icon
을 포함하는content
는 반드시 전달해야 한다.
또한,content
는androidx.compose.material.icons.Icons
의 아이콘을 사용하는 아이콘이여야 한다. 만약사용자 지정 아이콘
을 사용하는 경우 내부 아이콘의 일반적인 크기는 24x24가 된다.- 참고로
IconButton
은Box
를 내부에 포함하고 있다. 해당 박스 내부에 우리가 전달한content(Icon)
이 그려지게 된다. - 클릭시 발생하는
이벤트
는Box
의Modifier
의clickable 인자
로 전달되어이벤트
가 처리된다.
- 참고로
좋아요 누른 유저 정보 레이아웃
Paging3
- Compose에서도 페이징을 처리하기 위해 Jetpack Paging Library를 사용하였다.
@Composable
private fun SetPostLikeUsers() {
val likeUsers = postViewModel.postLikeUsers.collectAsLazyPagingItems()
when (likeUsers.loadState.refresh) {
is LoadState.Loading -> {
//TODO implement loading state
}
is LoadState.Error -> {
//TODO implement error state
}
else -> {
SetPostLikeUsersLayout(likeUsers = likeUsers)
}
}
}
postViewModel
으로부터poseLikeUsers
을 통해좋아요를 누른 유저리스트가 담긴 정보
를 받아오기 위해viewModel
로부터flow
를collect
를 위한collectsLazyPagingItems
을 사용해lazyPagingItems
로 받아오게 된다.
이를 이용하게 되면,Compose
는 유저가 로드한 리스트의 끝에 도달하면 해당 객체를 어떻게 handle하고, 더 많은 아이템을 request하기 위한 방법을 알고 있다.
따라서, 해당 객체를composable
화면에 전달하면 된다.- 추가로,
LazyPagingItems
는LazyColumn
,LazyRow
,LazyHorizontalGrid
orLazyVerticalGrid
과 같은 lazy 레이아웃에 pass할 수 있다. 자세한 정보는 here를 참고하자 collectAsLazyPagingItmes()
는 cold flow를LazyPagingItems
인스턴스로 변환한다.
- 추가로,
LazyPagingItems
은LoadState
에 대한 정보를 가지고 있기 때문에loading state
를 handling할 수 있다.
위 코드에서는refresh event
(처음 로드하거나, data를 invalidating하는)에만 동작하는 코드를 작성했지만,append
,prepend
등에 대해서도 handling할수 있다.
LazyColumn
- 기존에
RecyclerView
로 구성했던리스트뷰
를LazyColumn
으로 구현할 수 있다.- 다만 RecyclerView처럼 하위 항목을 재사용하지는 않는다. 대신 스크롤을 따라 새로운 Composable들을 emit하는데, Android View를 인스턴스화하는 것보다 Composable을 emit하는 것이 상대적으로 Cheap하기 때문에 재활용하지 않더라도 여전히 성능이 좋다.
@Composable
private fun SetPostLikeUsersLayout(likeUsers: LazyPagingItems<LikeUser>) {
LazyColumn(
modifier = Modifier
.padding(vertical = 4.dp)
.fillMaxSize()
.background(color = colorResource(id = R.color.white_FFFFFF))
) {
items(likeUsers.itemCount) { index ->
val item = likeUsers[index]
LikeUserLayout(likeUser = item!!)
}
}
}
- 이렇게 하면, 유저 리스트를 보여줄 수 있다.
RecyclerView
를 이용해 리스트 뷰를 구현했던 것과 비교하면 엄청나게 간단해진 셈이다.- 기존
RecyclerView
구현시 작성해야 했던 항목들RecyclerViewAdapter
생성- 유저 정보에 대한
XML
및ViewHolder
생성
- 기존
items
는Composable
을 반복해서 나타내고자할 때 사용한다. 여기에서는 유저의 정보를 보여주는 부분에 해당한다.
특히items
를 여러 개 구성할 수 있어서 더이상ConcatAdapter
같은 것도 필요없다.items
의parameter
에는 코드에서처럼특정 item 리스트의 count
를 넣을수도 있고,특정 item
을 직접 넣을수도 있다.- 다만, 불과 며칠전에
paging-compose
가 3.2.0으로 업데이트 되면서,LazyListScope
만 지원하는items(lazyPagingItems)
나itemsIndexed(lazyPagingItem)
는 Deprecated 되었다. 따라서 이전에LazyColumn
과paging3
을 함께 구현했던 여러 블로그에서의 예제 중items(LazyPagingItems)
는 더이상 사용할 수 없다.count
만 넣어야할 듯 하다.
- 다만, 불과 며칠전에
User Layout
@Composable
private fun LikeUserLayout(likeUser: LikeUser) {
Surface(
color = colorResource(id = R.color.white_FFFFFF),
modifier = Modifier.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickableSingle { navigateUserProfile(likeUser.memberId) }
.padding(horizontal = 18.dp, vertical = 8.dp)
) {
LikeUserImageLayout(likeUser = likeUser)
LikeUserNicknameLayout(userNickname = likeUser.nickname)
Spacer(modifier = Modifier.weight(1f))
if (likeUser.memberId != DayoApplication.preferences.getCurrentUser().memberId) {
LikeUserFollowLayout(likeUser = likeUser)
}
}
}
}
- Compose에는 margin이 없고, padding만이 존재한다. Modifier에서 padding이 적용된 순서에 따라 margin처럼 적용되기도 하고, padding처럼 적용되기도 한다. 따라서, modifier를 사용할때의 코드의 순서가 매우 중요하다.만약, padding이 clickable보다 앞에 존재하는 경우엔, 우리가 알던 margin처럼 적용되어 padding으로 추가된 공간을 클릭하더라도 아무런 동작도 하지않게 된다.
이 경우, padding이 가장 마지막으로 적용되어있는데, clickable이 그 앞에 적용되어있다. 이렇게 적용시키게되면, 기존에 우리가 알던 padding처럼 적용되어 padding으로 추가된 공간을 포함에 전체 영역을 클릭할 경우 clickable에 작성한 동작을 하게된다.
만약 유저가 접속한 본인이라면 팔로우 버튼이 보여져서는 안된다. 따라서, 만드려고 하는 유저 UI가 본인인지에 대한 State에 depend해서 버튼이 만들어지도록 설정했다. - 좌측에 유저의 이미지와 닉네임이 나타나고 우측에 버튼이 나타나는 형태이다. 이미지와 닉네임은 좌측으로 align시키고, 버튼은 우측으로 align시켜야 하기 때문에 닉네임과 버튼 사이에 Spacer를 통해 공간을 주고 weight를 적용시켜 닉네임, 이미지, 버튼의 영역이외에는 Spacer가 모두 채울 수 있도록 공백을 추가시켜줬다.
- 닉네임에 가중치를 줘도 되지만, 이 경우 닉네임의 영역이 버튼 직전까지 채워지기 때문에 Spacer를 추가시켜주는 것이 적절하다고 생각했다.
Glide Image
@Composable
private fun LikeUserImageLayout(likeUser: LikeUser) {
GlideImage(
imageModel = { "Profile Image Url" },
imageOptions = ImageOptions(
contentDescription = "image description",
contentScale = ContentScale.Crop,
),
modifier = Modifier
.height(USER_THUMBNAIL_SIZE.dp)
.aspectRatio(1f)
.clip(CircleShape)
)
}
- 이미지를 표시하기 위해서는 ImageLoader가 필수적인데, 기존엔 Glide를 사용했지만 아직 Glide에서 공식적으로 Compose를 지원하지는 않는것 같다. 다행히, 이러한 상황에 대해 Compose로 Glide로 사용할 수 있게 해놓은 오픈소스가 있어서 손쉽게 사용할 수 있었다.
- 해당 라이브러리는 Glide이외에도 Coil 등 몇 개의 이미지 로더 라이브러리를 지원하는데, 기존에 Glide를 사용중이었기 때문에, 동일한 스펙으로 맞추는게 좋을 것 같아 GlideImage를 선택하였다.
- 1:1 비율의 원 모양인 이미지가 필요했기 때문에, Image를 Crop하고,
asepectRatio
를 통해 비율을 설정해 준뒤, clip을 통해 원 모양을 설정해주었다.
Follow Button
@Composable
private fun LikeUserFollowLayout(likeUser: LikeUser) {
val followInteractionSource = remember { MutableInteractionSource() }
val followIsPressed by followInteractionSource.collectIsPressedAsState()
var followState by rememberSaveable { mutableStateOf(likeUser.follow) }
val followSuccess by ..
val unFollowSuccess by ..
TextButton(
onClick = {
if (followState) {
DeleteFollow()
if (unFollowSuccess) followState = false
} else {
CreateFollow()
if (followSuccess) followState = true
}
},
interactionSource = followInteractionSource,
colors = ButtonDefaults.buttonColors(
if (followState or (!followState and followIsPressed)) colorResource(id = R.color.white_FFFFFF)
else colorResource(id = R.color.primary_green_23C882)
),
contentPadding = PaddingValues(0.dp),
modifier = Modifier
.defaultMinSize(1.dp)
.border(
width = 1.dp,
color =
if (followState or (!followState and followIsPressed)) colorResource(id = R.color.gray_3_9FA5AE)
else colorResource(id = R.color.primary_green_23C882),
shape = RoundedCornerShape(size = FOLLOW_BUTTON_RADIUS_SIZE.dp),
)
.background(
color =
if (followState or (!followState and followIsPressed)) colorResource(id = R.color.white_FFFFFF)
else colorResource(id = R.color.primary_green_23C882),
shape = RoundedCornerShape(size = FOLLOW_BUTTON_RADIUS_SIZE.dp)
)
.width(FOLLOW_BUTTON_WIDTH.dp)
.height(FOLLOW_BUTTON_HEIGHT.dp)
) {
Text(
text = if (followState) stringResource(id = R.string.follow_already)
else stringResource(id = R.string.follow_yet),
style = TextStyle(
fontSize = 14.sp,
lineHeight = 21.sp,
fontWeight = FontWeight(600),
color = if (followState or (!followState and followIsPressed)) colorResource(id = R.color.gray_3_9FA5AE)
else colorResource(id = R.color.white_FFFFFF),
textAlign = TextAlign.Center,
),
modifier = Modifier
)
}
}
- 현재 만든 화면에서 가장 복잡했던 코드였는데, State에 따라서 변화되어야 하는 것들이 많았기 때문에 좀 더 신경써서 구현했어야 했다. 다만, 잘 짠 코드라고 생각하진 않아서 아쉬웠지만.. 좀 더 살펴봐서 개선시켜나가고 싶다.
- Jetpack Compose에서 데이터의 상태관리는 Recomposition 될 때에 따라 잘 관리되어야 한다. 이때 remember를 사용하면 특정 상태 변경으로 인해 Recomposition이 되더라도 데이터를 보존시켜줄 수 있다.
하지만, Configuration이 변경되는 등의 상황에서는 보존을 보장하지 못하므로, 이때는 자동으로 Bundle에 값을 저장할 수 있는 rememberSaveable을 사용해야 한다.
이 rememberSaveable은 직접 Saver를 구현해 저장하는 조건등의 로직을 커스텀할 수 있도록 구현되어 있다. - 해당 함수는 LazyColumn 내부에서 호출되고 있다. LazyColumn 자체가 RecyclerView처럼 화면에 보여지는 Composable만을 표시하는 scrollable한 Column이다. 그렇기 때문에, 화면 로딩 시간을 최적화 시킬 수 있다는 장점을 가지고 있다.
하지만, 이러한 이유인 탓인지, LazyColumn 내부에서 remember을 이용해 특정값을 유지시키려고 하더라도 스크롤을 내리는 등의 동작으로 composable이 보여지지 않으면 특정값 유지가 되지 않는 문제가 발생한다. 스크롤을 내리면서 composable이 destroy되고 그에따라 dispose됨에 따라 값이 유지되지 않고 리셋되는 것으로 보인다. - 따라서, 화면을 내리더라도 상태를 유지하기 위해서는, LazyColumn 바깥 영역에서 remember을 사용하거나, Bundle에 값이 저장되는 rememberSaveable을 사용해야 한다.
위와 같은 화면에서는 LazyColumn 하나하나 상태를 기억해줬어야 했으며, Decomposition이 된 후에 다시 composition이 이루어지더라도 상태가 유지되었어야 했다.
기본적으로 처음 화면이 보여질 때 최초의 팔로우 여부를 설정한 후, 유저가 팔로우를 하거나 팔로우를 취소하는 상태를 저장하고 스크롤하더라도 그 모습이 유지되어야하기 때문이다. 따라서 이경우에 followState에 대해 rememberSaveable을 사용하였다. - 다만, followInteractionSource의 경우 버튼이 press되고 있는지 여부에 대해 판단하기 위함이였기 때문에 Decomposition되고 다시 initial Composition되었을 때 초기화되더라도 상관없었기 때문에 remember로 작성해주었다.
반응형