원티드 앱에 Compose 적용해보기 - 1

Nampyo Jeong
원티드랩 기술 블로그
16 min readSep 13, 2021

고대했던 compose stable 버전이 2021.7.28.에 릴리즈 되었습니다. 작은 범위로 조금씩 적용해보고 경험했던 것들을 공유해보려고 합니다.
저도 공부해가면서 적용하는거라 아직은 수준이 낮지만 compose 도입을 고민하시는 분들께 조금이나마 도움이 되었으면 좋겠습니다.

첫 작업은 최대한 화면이 간단하고 중요도가 낮은 곳을 compose로 변환하려고 했습니다. 마침 적당한 화면이 커뮤니티 가이드 화면이었습니다. 제가 작업하기도 했고 앱 설치 기준으로 한 번만 뜨는 화면이어서 사이드 이펙트도 적어서 테스트삼아 해보기 딱 좋았습니다.

원티드 커뮤니티에 최초 진입하면 뜨는 가이드 화면

(원티드 커뮤니티 많이 이용해주세요.)

Set up

공식문서를 따라 gradle을 설정해줍니다.

필수 조치

  • Android Studio Arctic Fox
  • Kotlin 1.5.21 이상 (ui-tooling에 필요)

많은 dependency가 있지만 필수라고 생각되는 ui, tooling, material만 적용했습니다.

dependencies {
implementation 'androidx.compose.ui:ui:1.0.1'
// Tooling support (Previews, etc.)
implementation 'androidx.compose.ui:ui-tooling:1.0.1'

// Material Design
implementation 'androidx.compose.material:material:1.0.1'
}

Fragment에서 ComposeView 설정

Compose를 적용할 곳은 위에 설명드렸듯이 커뮤니티 가이드 화면입니다.

Compose는 view를 생성하는 방식이 Activity와 Fragment가 약간 다른데 커뮤니티 가이드 화면은 BottomSheetDialogFragment로 구성되어 있어 fragment의 방식을 따릅니다.

class CommunityGuideBottomDialog : BottomSheetDialogFragment() {

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
// Your composable
CommunityGuide()
}
}
}
}

fragment에서는 onCreateView에서 ComposeView를 생성해주면 됩니다.

setContent를 Theme로 감싸주는 게 일반적인 형태지만 지금은 굳이 필요하지 않기 때문에 생략했습니다. (차후에는 Custom theme를 만들어둘 필요가 있을 것 같습니다.)

UI 구성하기

가장 기본적인 Composable UI를 작성하는 방식은 Column, Row를 사용하는데 LinearLayout과 유사합니다. (Flutter를 경험해보신 분이라면 아주 익숙하다고 느끼실 것 같습니다.)

가이드 화면은 구성요소가 세로로만 되어 있기 때문에 Column으로 작성합니다.

@Composable
private fun CommunityGuide() {
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = colorResource(
R.color.semantic_elevated_bg
))
.padding(top = 35.dp, bottom = 34.dp)
) {

}
}

Compose에는 margin이 없고 padding만 있습니다. Modifier에서 padding이 적용된 순서에 따라 margin처럼 적용되기도 하고 padding처럼 적용되기도 합니다. 그렇기 때문에 modifier를 사용할 때는 코드의 순서가 매우 중요합니다.

예를 들어, 위 Column에서 padding은 가장 마지막에 적용되어 padding의 형태입니다. 만약 background color 설정하기 전에 padding이 있었다면 기존 UI의 margin처럼 적용됩니다.

리소스를 가져오는 방법은 colorResource, stringResource, painterResource 등이 있습니다. 기존 UI에서 사용하는 것처럼 리소스 아이디를 사용하시면 됩니다. (context를 가져오는 방법도 있지만 특별한 경우가 아니라면 굳이 사용할 필요가 없습니다.)

Round shape

가이드 화면에는 상단에만 Round shape이 적용되어야 합니다.

기존 xml 형식의 UI에서는 상단 round가 적용된 xml drawable을 생성하거나, 아니면 코드레벨에서 별도의 draw 로직을 적용해야했습니다.

@Composable
private fun CommunityGuide() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(
topStart = 15.dp,
topEnd = 15.dp,
))

.background(color = colorResource(
R.color.semantic_elevated_bg
))
.padding(top = 35.dp, bottom = 34.dp)
) {

}
}

Compose에서는 modifier에 clip을 적용해주면 끝입니다.
다만 위에 설명드렸듯이 순서가 중요하기 때문에 background보다 앞에 적용해야 제대로 동작합니다.

Spacer와 공통 UI 재사용

타이틀 부분을 구현합니다. Text 부분은 특별한 게 없으니 빠르게 넘어가겠습니다.

@Composable
private fun CommunityGuide() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(
topStart = 15.dp,
topEnd = 15.dp,
))
.background(color = colorResource(
R.color.semantic_elevated_bg
))
.padding(top = 35.dp, bottom = 34.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp),
text = stringResource(
R.string.user_community_popup_guide_title
),
style = TextStyle(
color = colorResource(R.color.neutral_gray_900),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
}
}

타이틀 아래 Content 부분은 반복되고 있어서 별도의 뷰로 구성합니다. Compose에서 공통 UI 분리는 단순 함수와 동일하기 때문에 재사용할 수 있게 구성하기 매우 편리합니다. (include layout은 이제 안녕~)

@Composable
private fun CommunityGuide() {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(
topStart = 15.dp,
topEnd = 15.dp,
))
.background(color = colorResource(
R.color.semantic_elevated_bg
))
.padding(top = 35.dp, bottom = 34.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp),
text = stringResource(
R.string.user_community_popup_guide_title
),
style = TextStyle(
color = colorResource(R.color.neutral_gray_900),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
Spacer(Modifier.height(27.dp))
ContentText(stringResource(
R.string.user_community_popup_guide_contents1
))
Spacer(
Modifier.height(7.dp))
ContentText(stringResource(
R.string.user_community_popup_guide_contents2
))
Spacer(
Modifier.height(43.dp))

}
}
@Composable
private fun ContentText(
text: String
) {
val style = TextStyle(
color = colorResource(R.color.neutral_gray_900),
fontSize = 16.sp,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp),
) {
Text(
text = stringResource(R.string.word_bullet),
style = style,
)
Spacer(Modifier.width(5.dp))
Text(
text = text,
modifier = Modifier.fillMaxWidth(),
style = style,
)
}
}

View 사이에 margin을 적용하려면 modifier에 padding을 설정할 수도 있지만(margin처럼) 저는 더 간결하고 가독성이 좋다고 생각되어 Spacer를 사용했습니다.

기존 View를 Composable에서 사용하기 (feat. 알 수 없는 오류)

마지막 하단 버튼만 추가하면 끝인데 저는 여기서 가장 큰 난관을 마주쳤습니다.

원티드에서는 디자인 시스템을 적용 중이고, 하단 버튼은 디자인 시스템에 맞춰 커스텀 뷰로 만들어두고 사용해왔습니다.

기존 디자인 시스템 버튼을 Compose 변환 혹은 복제하기엔 영향범위가 너무 커질 우려가 있어서 Composable에서 기존 방식의 View를 사용하는 방법을 찾아보니 AndroidView와 AndroidViewBinding 두 가지 방법이 있었습니다. (link)

그래서 AndroidView를 적용해 봤더니… (아앗…)

AndroidView(
factory = { context -> Button(context) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
update = { btn ->
btn.text = btn.context.getString(
R.string.user_community_popup_guide_btn_title
)
btn.type = Button.Type.FILL_BLUE
btn.size = Button.Size.LARGE

btn.clickOnce { onClickButton.invoke() }
}
,
)

버튼의 타이틀이 나오지 않았습니다 ㅠㅠ (compose 1.0.1 기준)

아직도 정확히 원인은 파악하지 못 했고 커스텀 뷰 안에서 이뤄지는 addView가 제대로 동작하지 않아서 생긴 이슈라는 것만 확인했습니다.

어쩔 수 없이 AndroidViewBinding을 시도했고 다행히 ViewBinding은 정상적으로 동작했습니다.
(AndroidViewBinding을 사용하려면 ui-viewbinding dependency가 필요합니다.)

// ds_button.xml<?xml version="1.0" encoding="utf-8"?>
<com.wanted.android.wanted.design.button.Button
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

-------------------------------------------------------------
// Composable code
AndroidViewBinding(
factory = DsButtonBinding::inflate,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
update = {
root.text = root.context.getString(
R.string.user_community_popup_guide_btn_title
)
root.type = Button.Type.FILL_BLUE
root.size = Button.Size.LARGE

root.clickOnce { onClickButton.invoke() }
},
)

완료

최종 코드입니다.

@Composable
private fun CommunityGuide(
onClickButton: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(
topStart = 15.dp,
topEnd = 15.dp,
))
.background(color = colorResource(
R.color.semantic_elevated_bg
))
.padding(top = 35.dp, bottom = 34.dp)
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp),
text = stringResource(
R.string.user_community_popup_guide_title
),
style = TextStyle(
color = colorResource(R.color.neutral_gray_900),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
),
)
Spacer(Modifier.height(27.dp))
ContentText(stringResource(
R.string.user_community_popup_guide_contents1
))
Spacer(
Modifier.height(7.dp))
ContentText(stringResource(
R.string.user_community_popup_guide_contents2
))
Spacer(
Modifier.height(43.dp))
AndroidViewBinding(
factory = DsButtonBinding::inflate,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp),
update = {
root.text = root.context.getString(
R.string.user_community_popup_guide_btn_title
)
root.type = Button.Type.FILL_BLUE
root.size = Button.Size.LARGE

root.clickOnce { onClickButton.invoke() }
},
)
}
}
@Composable
private fun ContentText(
text: String
) {
val style = TextStyle(
color = colorResource(R.color.neutral_gray_900),
fontSize = 16.sp,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 25.dp),
) {
Text(
text = stringResource(R.string.word_bullet),
style = style,
)
Spacer(Modifier.width(5.dp))
Text(
text = text,
modifier = Modifier.fillMaxWidth(),
style = style,
)
}
}

마무리

Flutter를 통해 선언형 UI의 장점을 많이 느끼고 안드로이드에도 빨리 도입되길 기다려왔는데 올해 드디어 compose stable이 나와서 정말 기뻤습니다. 아직은 저도 미흡하고 compose도 보완해야할 것들이 많지만 이렇게 조금이나마 적용해 볼 수 있어서 좋았습니다. 앞으로도 종종 compose를 적용하고 얻은 교훈들을 공유해 볼 수 있도록 노력하겠습니다.
(주로 원티드 커뮤니티에 적용할 예정이니 커뮤니티도 많이 이용해주세요. 미리 감사합니다!)

기술 블로그를 처음 써봐서 부족한 부분이 많은데도 읽어주셔서 감사합니다.

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

--

--