android jetpack compose 공식 문서의 자료를 따라 학습합니다.
compose 공식 문서 : https://developer.android.com/courses/pathways/compose
컴때잡 github : https://github.com/dddooo9/compose-practice
Compose
는 네이티브 Android UI를 빌드하기 위한 최신 도구 키트로, 더 적은 수의 코드, 강력한 도구, 직관적인 Kotlin API로 Android UI 개발을 간소화하고 가속화합니다.
XML 레이아웃을 수정하거나 Layout Editor를 사용하지 않고, Composable function을 호출해 원하는 요소를 정의하면 Compose 컴파일러가 나머지 작업을 완료합니다.
compose는 composable function을 사용해 UI의 구성 과정(요소 초기화, 부모에 연결 등) 보다는, 어떻게 생겼는지를 묘사하고 데이터 종속 항목을 제공해 프로그래매틱 방식으로 앱의 UI를 정의할 수 있습니다.
composable function을 만들기 위해서는 함수 이름에 @Composable
어노테이션을 추가하면 됩니다.
Compose 앱을 생성하기 위해서는 Android Studio에서 새 프로젝트를 생성해야 합니다.
프로젝트 생성 시, 아래와 같이 Empty Compose Activity
를 선택합니다.
이렇게 생성된 compose 프로젝트에는 기존에 생성했던 프로젝트의 MainActivity와는 매우 다른 코드들이 작성되어 있습니다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposePracticeTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Greeting("Android")
}
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
ComposePracticeTheme {
Greeting("Android")
}
}
*공식문서의 튜토리얼을 따라 단계적으로 학습하기 위해 해당 코드는 지우고 튜토리얼을 따랐습니다.
compose UI를 통해 Text를 추가하는 방법은 아래와 같습니다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Text("Hello world")
}
}
}
Text
함수에 넘겨주는 텍스트 라벨을 화면에 표시합니다.
composable function을 정의하기 위해서는 @Composable
annotation을 추가하면 됩니다.
아래는 파라미터로 받은 이름을 Text로 표시하는 composable function인 MessageCard
를 정의한 예제입니다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MessageCard(name = "Android")
}
}
}
@Composable
fun MessageCard(name: String) {
Text(text = "Hello $name")
}
결과는 아래와 같습니다.
composable function은 @Preview
annotation을 사용해 Android Studio 내에서 그 결과를 미리 볼 수 있습니다.
대신 매개변수를 사용하지 않는 composable function에 사용해야 합니다.
따라서 위에서 작성한 MessageCard가 아닌, preview 하기 위한 composable function으르 새롭게 작성합니다.
@Preview
@Composable
fun PreviewMessageCard() {
MessageCard(name = "Android")
}
해당 함수를 작성 후, 프로젝트를 다시 빌드한 뒤에 오른쪽 상단에 위치한 split 탭을 눌러 아래와 같이 preview를 확인할 수 있습니다.
UI 요소는 계층적이며 다른 요소에 포함된 요소가 있습니다.
compose에서는 다른 composable function에서 composable function을 호출해 UI 계층 구조를 빌드합니다.
해당 튜토리얼은 메시지 목록이 포함된 간단한 메시지 화면을 빌드하여 다양한 compose의 기능을 살펴보는 것이 목표입니다.
먼저 메시지 작성자의 이름과 내용을 표시해 message composable을 더 복잡하게 만들어봅니다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MessageCard(Message("Android", "Jetpack Compose"))
}
}
}
data class Message(val author: String, val body: String)
@Composable
fun MessageCard(msg: Message) {
Text(text = msg.author)
Text(text = msg.body)
}
@Preview
@Composable
fun PreviewMessageCard() {
MessageCard(
msg = Message("Colleague", "Hey, take a look at Jetpack Compose, it's great!")
)
}
data class로 정의한 Message 객체를 매개변수로 넘겨 author, body를 텍스트로 표시합니다.
위와 같이 텍스트 두 개를 정상적으로 표시하지만, 정렬에 대한 정보가 없어 서로 위에 겹치게 표시되어있다는 문제가 있습니다.
Column
함수를 사용해 요소를 수직으로, Row
함수를 사용해 수평으로 정렬할 수 있고, Box
를 사용해 요소를 쌓을 수 있습니다.
겹쳐진 텍스트를 정렬하여 표시하기 위해 Column
을 사용합니다.
@Composable
fun MessageCard(msg: Message) {
Column {
Text(text = msg.author)
Text(text = msg.body)
}
}
메시지 발신자의 프로필 사진을 추가합니다.
정렬을 위해 Row
로 감싸고, Image
를 추가합니다.
@Composable
fun MessageCard(msg: Message) {
Row {
Image(
painter = painterResource(id = R.drawable.profile_picture),
contentDescription = "Contact profile picture",
)
Column {
Text(text = msg.author)
Text(text = msg.body)
}
}
}
composable의 크기, 레이아웃, 모양을 변경하거나 clickable하게 만드는 등의 동작을 수행하기 위해 Modifier
를 사용할 수 있습니다.
일부 레이아웃 개선을 위해 Modifier를 사용합니다.
@Composable
fun MessageCard(msg: Message) {
Row(modifier = Modifier.padding(8.dp)) {
Image(
painter = painterResource(id = R.drawable.profile_picture),
contentDescription = "Contact profile picture",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(text = msg.author)
Spacer(modifier = Modifier.height(4.dp))
Text(text = msg.body)
}
}
}
이미지를 원형으로 출력하고, 크기나 spacing에 대해 설정해줍니다.
*xml로 레이아웃을 구성할 때 원형 이미지를 만들기 위해서는 drawable을 만들어 뷰에 지정해주는 등의 추가 작업이 필요했던 반면, compose 사용시 CircleShape을 사용하여 이미지 형태를 쉽게 지정해줄 수 있습니다.
compose는 Material Design을 지원하도록 빌드되어 있습니다.
Material Design을 사용해 MessageCard composable의 디자인을 개선해보겠습니다.
프로젝트 생성시에 지정한 앱 이름에 따라 자동적으로 material theme이 생성됩니다.
해당 theme으로 MessageCard 함수를 wrapping하여 사용합니다.
Preview와 setContent 함수 모두에서 이 작업을 실행해야 composable이 앱 테마에 정의된 스타일을 상속하여 앱 전체에서 일관성이 보장됩니다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposePracticeTheme {
MessageCard(Message("Android", "Jetpack Compose"))
}
}
}
}
// ...
@Preview
@Composable
fun PreviewMessageCard() {
ComposePracticeTheme {
MessageCard(
msg = Message("Colleague", "Hey, take a look at Jetpack Compose, it's great!")
)
}
}
MaterialTheme.colors
를 사용하여 래핑된 테마의 색으로 스타일을 지정할 수 있습니다.
제목 스타일을 지정하고 이미지에 테두리를 추가합니다.
@Composable
fun MessageCard(msg: Message) {
Row(modifier = Modifier.padding(8.dp)) {
Image(
painter = painterResource(id = R.drawable.profile_picture),
contentDescription = "Contact profile picture",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = msg.author,
color = MaterialTheme.colors.secondaryVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(text = msg.body)
}
}
}
Text composable에 추가하여 스타일을 지정할 수 있다.
@Composable
fun MessageCard(msg: Message) {
Row(modifier = Modifier.padding(8.dp)) {
Image(
painter = painterResource(id = R.drawable.profile_picture),
contentDescription = "Contact profile picture",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = msg.author,
color = MaterialTheme.colors.secondaryVariant,
style = MaterialTheme.typography.subtitle2
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = msg.body,
style = MaterialTheme.typography.body2
)
}
}
}
Shape
을 사용해 마지막 작업을 할 수 있습니다.
Surface
로 메시지 본문 영역을 wrapping 할 수 있습니다.
@Composable
fun MessageCard(msg: Message) {
Row(modifier = Modifier.padding(8.dp)) {
Image(
painter = painterResource(id = R.drawable.profile_picture),
contentDescription = "Contact profile picture",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Text(
text = msg.author,
color = MaterialTheme.colors.secondaryVariant,
style = MaterialTheme.typography.subtitle2
)
Spacer(modifier = Modifier.height(4.dp))
Surface(shape = MaterialTheme.shapes.medium, elevation = 1.dp) {
Text(
text = msg.body,
modifier = Modifier.padding(all = 4.dp),
style = MaterialTheme.typography.body2
)
}
}
}
}
메시지 영역에 elevation이 추가되고, padding이 지정된 것을 확인할 수 있습니다.
다크 모드 대응시, preview를 통해서도 확인할 수 있습니다.
@Preview(name = "Light Mode")
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewMessageCard() {
ComposePracticeTheme {
MessageCard(
msg = Message("Colleague", "Hey, take a look at Jetpack Compose, it's great!")
)
}
}
지정한 name 대로 라이트 모드와 다크 모드의 preview를 아래와 같이 확인할 수 있습니다.
각 테마의 색상은 Theme.kt
파일에 정의되어 있습니다.
compose를 통해 list를 손쉽게 만들고, animation을 추가할 수 있습니다.
메시지 두 개 이상 포함하는 대화뷰를 구현하기 위해 Conversation 함수를 만들어야 합니다.
LazyColumn
, LazyRow
는 화면에 표시되는 요소만 렌더링하여 긴 List에 효율적인 composable 입니다.
*Recyclerview가 스크롤 시 화면에 보이는 영역의 뷰만 렌더링된다는 점에서 유사함을 확인할 수 있습니다.
LazyColumn/LazyRow의 하위 요소인 items
를 사용해 List 매개변수를 가져와 항목마다 람다식을 호출합니다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ComposePracticeTheme {
Conversation(SampleData.conversationSample)
}
}
}
}
// ...
@Composable
fun Conversation(messages: List<Message>) {
LazyColumn {
items(messages) { message ->
MessageCard(msg = message)
}
}
}
@Preview(name = "Light Mode")
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewConversation() {
ComposePracticeTheme {
Conversation(SampleData.conversationSample)
}
}
아래와 같은 화면을 확인할 수 있으며, 수직 스크롤로 모든 데이터를 표시했음을 알 수 있습니다.
긴 메시지는 확장하여 볼 수 있도록 animation을 적용해보도록 하겠습니다.
이를 구현하기 위해서 로컬 UI 상태 변경을 추적하기 위해 remember
와 mutableStateOf
함수를 사용해야 합니다.
composable 함수는 remember
를 사용해 메모리에 로컬 상태를 저장하고, mutableStateOf
에 전달된 값의 변경사항을 추적할 수 있습니다.
여기서 값이 업데이트되면 자동으로 다시 그려지는 Recomposition
이 발생합니다.
클릭 이벤트를 처리하기 위해 clickable
modifier를 사용합니다.
@Composable
fun MessageCard(msg: Message) {
Row(modifier = Modifier.padding(8.dp)) {
Image(
painter = painterResource(id = R.drawable.profile_picture),
contentDescription = "Contact profile picture",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
var isExpanded by remember { mutableStateOf(false)}
Column(modifier = Modifier.clickable { isExpanded = !isExpanded }) {
Text(
text = msg.author,
color = MaterialTheme.colors.secondaryVariant,
style = MaterialTheme.typography.subtitle2
)
Spacer(modifier = Modifier.height(4.dp))
Surface(shape = MaterialTheme.shapes.medium, elevation = 1.dp) {
Text(
text = msg.body,
modifier = Modifier.padding(all = 4.dp),
maxLines = if (isExpanded) Int.MAX_VALUE else 1,
style = MaterialTheme.typography.body2
)
}
}
}
}
아래와 같이 확장 여부에 따라 maxline 값을 변경해줌을 확인할 수 있습니다.
추가로 단순히 Surface의 배경색을 바꾸는 대신, animateColorAsState
함수를 사용하여 MaterialTheme의 색 두 가지를 갖고 값을 점진적으로 수정해 배경색에 animation을 적용할 수 있습니다.
그리고 animateContentSize
modifier를 사용해 메시지 container 크기 변화에도 animation을 적용합니다.
@Composable
fun MessageCard(msg: Message) {
Row(modifier = Modifier.padding(8.dp)) {
Image(
painter = painterResource(id = R.drawable.profile_picture),
contentDescription = "Contact profile picture",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colors.secondary, CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
var isExpanded by remember { mutableStateOf(false) }
val surfaceColor by animateColorAsState(
if (isExpanded) MaterialTheme.colors.primary else MaterialTheme.colors.surface
)
Column(modifier = Modifier.clickable { isExpanded = !isExpanded }) {
Text(
text = msg.author,
color = MaterialTheme.colors.secondaryVariant,
style = MaterialTheme.typography.subtitle2
)
Spacer(modifier = Modifier.height(4.dp))
Surface(
shape = MaterialTheme.shapes.medium,
elevation = 1.dp,
color = surfaceColor,
modifier = Modifier.animateContentSize().padding(1.dp)
) {
Text(
text = msg.body,
modifier = Modifier.padding(all = 4.dp),
maxLines = if (isExpanded) Int.MAX_VALUE else 1,
style = MaterialTheme.typography.body2
)
}
}
}
}
아래와 같이 메시지를 클릭하면 메시지 확장과 색 변경에 animation이 적용되었음을 확인할 수 있습니다.
지금까지 android jetpack compose 튜토리얼 문서를 바탕으로 compose 기초에 대해 학습하였습니다.
기존 xml로 레이아웃을 작업할 때는 kt 파일과 xml 파일을 이동하는 것도 번거로웠고 더 나은 UI를 위해 복잡한 Drawable을 추가해주어야 했는데 compose는 그러한 면에서 큰 이점을 갖는 것 같습니다.
상태 관리 등의 복잡한 비즈니스 로직과 연동하였을 때 compose의 효율성은 어떨지 더 학습해보아야 할 것 같습니다.😛