코틀린의 간결함과 편의성을 활용해서 반응형 프로그래밍 모델을 만든 것이 컴포즈임.
데이터 변경이 발생하면 프레임워크가 구성가능한 함수들을 재호출해서 UI 계층 구조를 업데이트하는 방식이다.
구성가능한 함수는 @Composable 어노테이션이 붙은 함수들이며, 일반함수들을 호출할 수 있지만 반대로 일반함수들은 이런 구성가능한 함수들을 호출할 수 없다. 참고로 컴포즈는 sdk 21이상부터 지원한다고 한다.
이제 하나하나 단계들을 따라가보며 코드랩을 시작해보자.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BasicsCodelabTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
}
}
}
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
BasicsCodelabTheme {
Greeting("Android")
}
}
위의 코드는 처음 컴포즈 Empty Project를 시작했을 때 생기는 코드다.
여기서 setContent 안에 있는 테마의 이름은 프로젝트 이름을 따라서 초기설정이 된다고 한다.
위에서 setContent를 통해 구성가능한 함수를 시작시키고 있다. 기존 뷰시스템에서 xml을 setContent로 액티비티의 뷰로 연결해주던 작업과 동일하다고 생각하면 된다.
컴포즈에서도 xml의 미리보기 기능처럼 내가 구성가능한 함수의 내부 로직을 바꿨을 때 뷰에 실제로 어떻게 반영되는지 빌드없이도 실시간으로 확인 가능한 미리보기 기능이 있다.
@Preview 어노테이션을 달아놓은 구성가능한 함수에다가 미리보고 싶은 구성가능한 함수를 시작(=호출)시키면 된다. (여기서 name속성은 미리보기 기능에서 해당 미리보기에 이름을 달아놓는 것이다)
@Preview(showBackground = true, name = "Text preview")
@Composable
fun DefaultPreview() {
BasicsCodelabTheme {
Greeting(name = "Android")
}
}
구성가능한 함수들이 모여서 하나의 UI가 된다고 했다. 이때, 그 조각들인 하나의 구성가능한 함수에 대해서만 배경색을 주고 싶은 경우 Surface라는 것을 구성가능한 함수의 맨 처음 요소로 감싸면 된다.
다음과 같이 말이다.
@Composable
fun Greeting(name: String) {
Text(text = "Hello $name!")
}
// 위의 것을 아래처럼 바꿔라.
@Composable
private fun Greeting(name: String) {
Surface(color = MaterialTheme.colorScheme.primary) {
Text (text = "Hello $name!")
}
}
Surface는 매터리얼 디자인과 관련되어있다. 실제로 기본 제공하는 Text같은 구성가능한 함수가 아니라 androidx.compose.material3.xxx 아래에 존재하는 요소다.
위의 코드를 미리보기로 확인해보면 다음과 같은 결과를 얻는다.
우리는 primary라는 바탕색만 지정해준 것 같은데... 언제 글씨가 하얀색이 되었을까? 바로 Surface에서 매터리얼 디자인 시스템이 정의해놓은대로 텍스트의 색상도 적절하게 선택한 것이다.
이제 드디어 레이아웃 내에서 UI요소를 세밀하게 배치할 수 있는 수정자(Modifier)라는 개념에 대해 배운다.
padding 수정자는 수정자가 데코레이션하는 요소 주변의 공간을 나타낸다.
우선, surface도 padding도 주지 않았을때는 다음과 같다. 여기에 그냥 Surface를 별도의 설정없이 그냥 추가한다면 미리보기의 결과는 기존과 똑같다.
이번에는 패딩을 Surface에도주고 Text에도 줘봤다.
이런식으로 이중 패딩이 잡히는 것을 알 수 있다.
마지막으로 내가 그냥 한 번 이것저것 실험해보며 확인한 결과들을 공유하고자 한다. 관심이 없다면 스킵하는 것을 추천한다.
가운데에 24dp가 벌어지게 보이는 것을 알 수 있다.
컴포즈의 장점인 재사용을 위해서는 컴포저블 함수를 가능한 작게 유지해야한다. 또한 위에서 본 것처럼, Surface를 통해 해당 컴포저블 함수의 전체적인 마진(뷰시스템상의 마진과 같은 효과를 말한것임)을 줄 수 있다. 재사용성있는 컴포저블 함수를 위해서는 컴포저블 함수의 매개변수로 수정자를 하나 받게 만들어서 외부에서 해당 UI요소에 대한 마진이나 배경색 등을 조정할 수 있게 해주는 것이다. 이 아래처럼 말이다.
@Composable
private fun MyApp(modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
}
}
컴포즈의 세 가지 기본 표준 레이아웃 요소: Column, Row, Box
이름에서도 알 수 있듯이 Column내부에 컴포저블 함수들을 선언하면 각각의 구성요소들이 세로로 배치된다.
@Composable
fun MyApp(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(modifier) {
for (name in names) {
Greeting(name = name)
}
}
}
@Composable
private fun Greeting(name: String) {
Surface(color = MaterialTheme.colorScheme.primary) {
Column(modifier = Modifier.padding(24.dp)) {
Text(text = "Hello,")
Text(text = name)
}
}
}
위의 코드를 보면 알 수 있듯이 컴포즈가 코틀린을 사용한다는 장점을 통해 for문과 같은 프로그래밍 코드를 활용해서 동적으로 뷰의 구성요소를 제어할 수 있다.
미리보기를 통해 확인해보면 아래와 같다.
크기나 제약사항에 대한 내용이 없으므로 각 행의 너비는 사용하는 최솟값으로 지정된다고 한다.
때문에 미리보기의 widthDp 속성을 활용해서 보면 다음처럼 보인다고 한다.
@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
BasicsCodelabTheme {
MyApp()
}
}
조금의 심화 버전
위와 같은 이미지처럼 뷰를 구성하고 싶다면 어떻게해야할까?
코드랩에서는 다음과같이 제안하고 있다. 하지만, 뷰를 구성하는 방법은 다양하므로 꼭 이것만은 정답이 아니다. 참고만 하면 될 것 같다.@Composable fun MyApp( modifier: Modifier = Modifier, names: List<String> = listOf("World", "Compose") ) { Column(modifier = modifier.padding(vertical = 4.dp)) { for (name in names) { Greeting(name = name) } } } @Composable private fun Greeting(name: String) { Surface( color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp) ) { Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) { Text(text = "Hello, ") Text(text = name) } } }
우선, Greeting 사이를 띄우기 위해서는 각 Greeting의 Surface에 패딩을 줘서 뷰시스템의 마진과 같은 효과를 줘야 한다. 여기서 세로마진은 4dp를 가로마진은 8dp를 주고 있는 이유는 뷰들끼리 세로마진이 중첩이 되어서 결국은 8dp만큼 떨어지기 때문이다.
그리고 Column 자체에서 전체적으로 위아래 4dp씩 패딩을 준 이유도 위의 이유와 같다.
이번에는 버튼을 넣어보자.
위와 같은 뷰를 구성하고 싶다면, Column이전에 먼저 Row로 감싸고, 기존의 Column과 새로운 버튼 요소를 넣으면 된다.
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.ElevatedButton
...
@Composable
private fun Greeting(name: String) {
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = { /* TODO */ }
) {
Text("Show more")
}
}
}
}
이제는 버튼을 클릭해서 항목을 펼치고 접을 수 있게 만들것이다.
이때, 각 항목이 펼쳐진 상태인지를 어딘가에 저장해야한다.
우선은 간단하게 아래처럼 불리언값 변수 하나를 선언하고 그 값에 따라 버튼의 글자만 바꿔주는 기능까지만 생각해보자.
정말 쉽게 생각한다면 아래와 같을 것이다.
// Don't copy over
@Composable
private fun Greeting(name: String) {
var expanded = false // Don't do this!
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = { expanded = !expanded }
) {
Text(if (expanded) "Show less" else "Show more")
}
}
}
}
하지만, 버튼을 클릭해도 아무일도 일어나지 않는다.
이유는 컴포즈에서 이 단순 불리언 값을 상태 변경으로 감지하지 않기 때문이다.
재구성이란?
컴포즈 앱은 구성 가능한 함수를 호출해서 데이터를 UI로 변경한다고 했다. 이 과정을 리컴포지션 즉 재구성이라고 하는데, 이 재구성은 자주 실행되며 순서와 무관하게 실행될 수 있다.
이런 재구성이라는 과정을 유발하기 위해서는 데이터를 컴포즈가 추적할 수 있는(재구성을 트리거하는) 내부상태인 State나 MutableState로 감싸주면 된다고 한다.
val expanded = mutableStateOf(false) // Don't do this!
하지만, 이는 재구성은 발생시키나 재구성의 특징인 함수가 다시 실행된다는 점 때문에 항상 false로 초기화가 되어서 클릭 이벤트로 값이 변경된것이 UI에 반영되지 못한다고 함.
그러므로 remember를 사용해서 변경 가능한 상태를 기억해야 한다.
val expanded = remember { mutableStateOf(false) }
remember를 사용하면 재구성시에도 해당 변수는 재초기화되지 않는다.
그렇다면 항목을 펼치기 위한 전체 코드를 최종적으로 보면 아래와 같다.
@Composable
private fun Greeting(name: String) {
val expanded = remember { mutableStateOf(false) }
val extraPadding = if (expanded.value) 48.dp else 0.dp
Surface(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp)
) {
Row(modifier = Modifier.padding(24.dp)) {
Column(modifier = Modifier
.weight(1f)
.padding(bottom = extraPadding)
) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = { expanded.value = !expanded.value }
) {
Text(if (expanded.value) "Show less" else "Show more")
}
}
}
}
여기서 extraPadding은 재구성 발생시 현재 expanded값에 따라 초기화되도록 정의되어있다. 여기서 onClick람다에서 해당 요소의 값을 바꾸고 remember를 쓰지 않는 이유는
굳이 remember를 사용해서 메모리를 낭비할 이유가 없는 간단한 로직이기 때문이라고 한다.
느낀점
확실히 유동적인 부분이나 재사용성에서 더 좋다는 생각은 든다. 하지만, xml이 아직 너무 편해서 그런가 이게 더 가독성이 좋다던지 하는 생각은 들지 않는다. 또한, 마진이라는 개념이 없으니 뷰를 만들 때 조금은 벙쪄지는 것 같다.
그래도 이런 부분들은 적응되면 해결될 것 같다. 컴포즈에 빨리 익숙해져서 플러터나 SwiftUI도 배워보고 싶다.