본문 바로가기

안드로이드/JetPack

[Android] Jetpack Compose로 개발하기

개요

이번 포스팅은 을 참고하여  아래 이미지와 같은 메시징 UI를 가진 앱을 Compose를 활용하여 개발하는 예시이다.

 

Composable Functions

Jetpack Compose는 Composable Functions으로 이루어진다. 이러한 함수를 사용하면 UI 구성 과정(기존의 xml 방식)에 초점을 맞추는 대신 앱의 UI를 설명하고 데이터의 종속성을 제공하는 프로그래밍 방식으로 앱의 UI를 정의할 수 있다.

 

Add a Text Element

첫번째로 "Hello World"라는 텍스트를 text 요소를 onCreate 내부에 사용하여 표시할 것이다. 그리고 그 과정에서 setContent 블럭을 활용하게 되는데 Composable Functions을 호출하는 액티비티의 레이아웃을 정의한다. Composable Functions은 다른 Composable Functions 내에서만 호출이 가능하다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Text("Hello world!")
        }
    }
}

 

 

Define a Composable Function

함수를 composable하게 만들기 위해 @Composable이라는 어노테이션을 붙여준다. 이를 확인하기 위해 MessageCard()라는 함수를 만들고 이 내부에 Text 요소를 구성한다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MessageCard("Android")
        }
    }
}

@Composable
fun MessageCard(name: String) {
    Text(text = "Hello $name!")
}

 

 

Layouts

UI 요소는 계층적이어서 한 요소는 다른 요소를 포함할 수 있다. Compose도 마찬가지로 한 composable function에서 다른 composable functions를 호출하여 계층적으로 생성할 수 있다.

 

Add Multiple Text

더 많은 Jetpack Compose 기능을 알아보기 위해 일부 애니메이션으로 확장이 가능한 메시지 화면을 만들 것이다. 먼저 작성자의 이름과 메시지 내용을 표시하여 메시지를 구성한다. String 대신 Message 객체를 받아 작성자와 메시지 내용을 을 표현하도록 Composable의 매개변수를 변경하고, MessageCard() 내부에 Text를 추가로 배치한다.

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!")
    )
}

 

Using a Column

Column 함수는 요소를 수직 방향으로 배치할 수 있는 함수이다. MessageCard() 함수에 Column을 사용하여 메시지 내용을 배치한다. 마찬가지로 Row를 사용하여 요소를 수평으로 배치할 수도 있다.

@Composable
fun MessageCard(msg: Message) {
    Column {
        Text(text = msg.author)
        Text(text = msg.body)
    }
}

 

Add an Image Element

메시지 카드를 더 보기 좋게 만들기 위해 전송자의 프로필 이미지를 추가하자. Resource Manager를 사용하여 이미지를 추가하도록 하자. Row를 사용하여 디자인을 보기 좋게 구성한다.

@Composable
fun MessageCard(msg: Message) {
    Row {
        Image(
            painter = painterResource(R.drawable.profile_picture),
            contentDescription = "Contact profile picture",
        )
    
       Column {
            Text(text = msg.author)
            Text(text = msg.body)
        }
  
    }
  
}

 

Configure Your Layout

지금까지 개발한 메시지 레이아웃은 우측 기준이고 요소들이 너무 붙어있고, 이미지도 크다. Composable을 보기 좋게 만들기 위해 modifiers를 사용한다. 이는 composable의 사이즈, 레이아웃, 외형 혹은 요소를 클릭 가능하게 하는 등의 고차원의 상호작용까지 지원한다. 

@Composable
fun MessageCard(msg: Message) {
    // Add padding around our message
    Row(modifier = Modifier.padding(all = 8.dp)) {
        Image(
            painter = painterResource(R.drawable.profile_picture),
            contentDescription = "Contact profile picture",
            modifier = Modifier
                // Set image size to 40 dp
                .size(40.dp)
                // Clip image to be shaped as a circle
                .clip(CircleShape)
        )

        // Add a horizontal space between the image and the column
        Spacer(modifier = Modifier.width(8.dp))

        Column {
            Text(text = msg.author)
            // Add a vertical space between the author and message texts
            Spacer(modifier = Modifier.height(4.dp))
            Text(text = msg.body)
        }
    }
}

 

Material Design

Compose는 material design 원칙을 따르도록 개발되었다. Material Widget으로 앱의 스타일을 구성해보자.

 

현재 개발한 메시지의 디자인은 아직도 보기 좋지는 않다. Jetpack Compose는 Material Design의 구현을 제공한다. 이를 위해 MessageCard 함수를 Material Theme으로 래핑해야한다.

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposeTutorialTheme {
                MessageCard(Message("Android", "Jetpack Compose"))
            }
        }
    }
}

@Preview
@Composable
fun PreviewMessageCard() {
    ComposeTutorialTheme {
        MessageCard(
            msg = Message("Colleague", "Take a look at Jetpack Compose, it's great!")
        )
    }
}

 

Lists and Animations

리스트와 애니메이션은 앱 어디에나 존재할 수 있다. 이러한 이유에서 Compose로 리스트와 애니메이션을 어떻게 만드는지 알아야 될 필요가 있다.

 

Create a List of Message

하나의 채팅만 보이기 보다는 여러 메시지가 보이는 방식으로 conversation을 바꾸면 좋을 것 같다. Conversation 함수를 만들고 여러 개의 메시지를 보이도록 만들자. 이 경우에 LazyColumn과  LazyRow를 사용할 수 있다. LazyColumn/Row는 screen에 보이는 요소만 렌더링하여 매우 긴 리스트에 효율적으로 설계되었다. 동시에 XML layout의 RecyclerView의 복잡성을 피할 수 있다.

 

아래 코드에서 LazyColumn이 한 개의 child 아이템을 갖고 있는 것을 볼 수 있다. List를 파라미터로 받고 람다 형식으로 message라는 이름의 child 아이템을 받는 구조이다. 요약하면, 이 람다는 List에 의해 제공되는 각각의 item을 호출한다.

import androidx.compose.foundation.lazy.items

@Composable
fun Conversation(messages: List<Message>) {
    LazyColumn {
        items(messages) { message ->
            MessageCard(message)
        }
    }
}

@Preview
@Composable
fun PreviewConversation() {
    ComposeTutorialTheme {
        Conversation(SampleData.conversationSample)
    }
}

 

Animate Messages Whild Expanding

Conversation에 애니메이션을 추가할 차례다. 아주 긴 메시지를 표시하기 위해 메시지를 펼칠 수 있도록 구현하자. 이 과정에서 내용의 크기와 배경색을 변경해야 한다. 이를 위해서 UI 상태를 저장할 필요가 있다. 메시지가 현재 펼쳐졌는지 확인해야 한다. 이를 위해서 remembermutableStateOf 함수를 사용한다.

 

Composable Functions은 remember를 통해 local state를 메모리에 저장하고, mutableStateOf 에 전달된 값의 변화를 추적할 수 있다. State를 사용하는 Composable은 값이 갱신되면 자동으로 다시 그려지며 이를 Recomposition이라 부른다.

class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           ComposeTutorialTheme {
               Conversation(SampleData.conversationSample)
           }
       }
   }
}

@Composable
fun MessageCard(msg: Message) {
    Row(modifier = Modifier.padding(all = 8.dp)) {
        Image(
            painter = painterResource(R.drawable.profile_picture),
            contentDescription = null,
            modifier = Modifier
                .size(40.dp)
                .clip(CircleShape)
                .border(1.5.dp, MaterialTheme.colors.secondaryVariant, CircleShape)
        )
        Spacer(modifier = Modifier.width(8.dp))

        // We keep track if the message is expanded or not in this
        // variable
        var isExpanded by remember { mutableStateOf(false) }

        // We toggle the isExpanded variable when we click on this Column
        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),
                    // If the message is expanded, we display all its content
                    // otherwise we only display the first line
                    maxLines = if (isExpanded) Int.MAX_VALUE else 1,
                    style = MaterialTheme.typography.body2
                )
            }
        }
    }
}

 

이제 isExpanded의 상태에 따라 메시지의 배경색을 바꿔야한다. Clickable modifier를 사용하여 클릭 이벤트를 처리할 수 있다. Surface의 배경색을 토글하는 대신 MaterialTheme.colors.surface에서 MaterialTheme.colors.primary로 또는 그 반대로 점진적으로 변경되는 방향으로 애니메이션을 적용한다. 이를 위해 imateColorAsState라는 함수를 사용해야 한다. 마지막으로 메시지의 컨테이너 크기를 부드럽게 움직이게 하기 위해 animateContentSize modifier를 사용한다.

@Composable
fun MessageCard(msg: Message) {
    Row(modifier = Modifier.padding(all = 8.dp)) {
        Image(
            painter = painterResource(R.drawable.profile_picture),
            contentDescription = null,
            modifier = Modifier
                .size(40.dp)
                .clip(CircleShape)
                .border(1.5.dp, MaterialTheme.colors.secondaryVariant, CircleShape)
        )
        Spacer(modifier = Modifier.width(8.dp))

        // We keep track if the message is expanded or not in this
        // variable
        var isExpanded by remember { mutableStateOf(false) }
        // surfaceColor will be updated gradually from one color to the other
        val surfaceColor: Color by animateColorAsState(
            if (isExpanded) MaterialTheme.colors.primary else MaterialTheme.colors.surface,
        )

        // We toggle the isExpanded variable when we click on this Column
        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,
                // surfaceColor color will be changing gradually from primary to surface
                color = surfaceColor,
                // animateContentSize will change the Surface size gradually
                modifier = Modifier.animateContentSize().padding(1.dp)
            ) {
                Text(
                    text = msg.body,
                    modifier = Modifier.padding(all = 4.dp),
                    // If the message is expanded, we display all its content
                    // otherwise we only display the first line
                    maxLines = if (isExpanded) Int.MAX_VALUE else 1,
                    style = MaterialTheme.typography.body2
                )
            }
        }
    }
}

 

참고