안드로이드 개발에서 UI 를 만드는 새로운 방법이다.
XML 등으로 만드는 전통적인 안드로이드 개발은 명령형 접근법(Inperative approach) 이라고 한다.
젯팩 컴포즈는 선언형 접근법(Declarative approach)이다. 구현하고자 하는 UI의 최종 상태를 명시하면 내부에서 필요한 작업이 수행되는 식이다.
@Composable
fun MyTextDisplay(myState: MyState) {
Text(text = myState.text, color = myState.color)
}
data class MyState(
val text: String,
val color: Color
)
@Composable 이 붙은 함수를 정의했다. 상태는 별도의 데이터 클래스로 정의했다. MyState 에서 발생하는 모든 변경 사항에 대응해 UI 를 다시 그린다. 이를 리컴포지션(recomposition) 이라고 한다. @Composable 함수를 컴포즈가 호출하는 방식이다.
새로운 화면을 만들 때는 Row, Column 함수를 선택한다. 세로 배치에는 Column, 가로 배치에는 Row 를 사용한다.
@Composable
fun MyScreen() {
Column {
Text(text = "My Static Text")
TextField(value = "My Text Field", onValueChange = {})
Button(onClick = { }) { }
Icon(painter = painterResource(R.drawable.icon),
contentDescription = stringResource(id = R.string.icon_content_description))
}
}
forEach 문을 통해 여러 개 만들 수도 있다.
@Composable
fun MyList(itemList: List<String>) {
Column {
itemList.forEach { str -> Text(text = str) }
}
}
스크롤이 필요한 경우엔 LazyColumn 을 사용하는 것이 유리하다.
@Composable
fun MyList(itemList: List<String>) {
LazyColumn {
item { Text(text = "Header") }
items(itemList) { str -> Text(text = str) }
item { Text(text = "Footer") }
}
}
요소에 Attribute 는 Modifier 를 통해 부여한다.
@Composable // 모든 방향 16dp 패딩
fun MyScreen() {
Column(
modifier = Modifier.padding(all = 16.dp)
) {
}
}
@Composable // 각 방향 다른 패딩값
fun MyScreen() {
Column(
modifier = Modifier.padding(
top = 5.dp, bottom = 5.dp,
start = 10.dp, end = 10.dp)
) {
}
}
@Composable // 수직 수평 패딩
fun MyScreen() {
Column(
modifier = Modifier.padding(
vertical = 5.dp,
horizontal = 10.dp)
) {
}
}
@Composable // 클릭 가능하게 만든다.
fun MyScreen() {
Column(
modifier = Modifier.padding(
vertical = 5.dp,
horizontal = 10.dp
).clickable { }
) {
}
}
이제는 액티비티 클래스에 ActivityCompat 대신 ComponentActivity 를 사용한다. 뷰를 불러오는 방식도 약간 다르다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstance: Bundle?) {
super.onCreate(savedInstance)
setContent { MyScreen() }
}
}
위의 예시에서 TextField 가 잠시 스쳐갔는데 입력이 되지 않을 것이다. 이는 리컴포지션과 사용자 액션 처리 쪽 작업이 필요하기 때문이다.
@Composable // 입력해도 값 안나옴. value 가 "" 인 TextField 만 리컴포지션 하기 때문
fun MyScreen() {
Column { TextField(value = "", onValueChange = {}) }
}
@Composable
fun MyScreen() {
var text by remember { mutableStateOf("") }
Column { TextField(value = text, onValueChange = { text = it }) }
}
젯팩 컴포즈에서는 @Composable 함수인 remember 를 사용해 텍스트를 저장하는 MutableState 가 있다. 이 변수는 리컴포지션 이후에도 값을 유지한다.
이 변수에 값을 반영하는 액션을 정의하고 실제 TextField 의 값으로 반영하도록 해야 한다.
액티비티가 새로 만들어질 때에도 변수의 값을 유지하려면 rememberSaveable 함수를 사용한다.
상태관리에 대해 구글은 stateful(하나 이상의 상태를 관리) 한 방식보단 stateless(상태를 관리하지 않음) 한 방식으로 구현할 것을 권고한다. 이를 위해 상태 호이스팅이란 방식을 사용하는데 @Composable 함수 호출자에게 자신의 상태 관리 책임을 이동 혹은 부여하는 방식이다.
@Composable
fun MyScreen() {
var text by rememberSaveable { mutableStateOf("") }
MyScreenContent(text = text, onTextChange = { text = it })
}
@Composable
fun MyScreenContent(text: String, onTextChange: (String) -> Unit) {
Column {
TextField(value = text, onValueChange = onTextChange)
}
}
MyScreen 함수의 상태를 MyScreenContent 가 가져다 쓴다. MyScreenContent 는 상태관리 하지 않는다. 이런 것이 stateless 하다는 것이다. 이는 함수 재사용성, 상태 관리 로직 분리, 상태에 대한 SSOT(Single source of truth. 단일 진실 공급원) 등 여러가지 이점을 제공한다.
State, MutableState 객체는 androidx.compose.runtime.getValue 와 androidx.compose.runtime.setValue 메서드를 갖는다. 이를 통해 값을 지정해줄 수도 있다.
상태가 변하면 리컴포지션이 일어난다. 여기서 주의할 것은 Snackbar, Toast 다. 일회성 이벤트가 리컴포지션에 의해 실행되지 않을 수도 있다. 이를 해결하기 위해 LaunchedEffect 를 사용한다.
@Composable
fun MyScreenContent() {
val context = LocalContext.current
LaunchedEffect(anObjectToChange) { // anObjectToChange 가 바뀌면 토스트 호출.
Toast.makeText(context, "Toast text", Toast.LENGTH_SHORT).show()
}
}
anObjectToChange 를 Unit 으로 대체하면 LaucnhedEffect 블록이 한 번만 실행된다.
컴포즈 탬플릿으로 (Empty Activity) 앱을 만들면 ui.theme 패키지가 자동으로 만들어지고 안에는 Color.kt, Shape.kt, Type.kt, Theme.kt 파일이 존재한다. 이 안에는 전역적으로 사용할 디자인 요소들이 정의되어 있다.
이를 Activity 에 적용하기 위해서는 아래와 같이 진행한다.
setContent {
MyApplicationTheme {
Surface(color = MaterialTheme.colorScheme.background) { }
}
}
Surface 함수 내에 들어가는 모든 뷰는 테마에 정의된 요소를 공통적으로 사용할 수 있다.
@Composable
fun ItemScreenContent(
itemScreenState: ItemScreenState
) {
LazyColumn {
items(itemScreenState.items) { item ->
Column(modifier = Modifier.padding(vertical = 4.dp)) {
OnBackgroundItemText(text = item)
}
}
}
}
@Composable
fun ItemScreen(itemCount: String) {
ItemScreenContent(itemScreenState =
ItemScreenState((1..itemCount.toInt()).toList()
.map {
stringResource(id = R.string.item_format,
formatArgs = arrayOf("$it"))
})
)
}
@Composable
fun ItemCountScreenContent(
itemCountScreenState: ItemCountScreenState,
onItemCountChange: (String) -> Unit,
onButtonClick: () -> Unit,
) {
Column {
OnBackgroundTitleText(text = stringResource(id = R.string.enter_number))
TextField(
value = itemCountScreenState.itemCount,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number),
onValueChange = onItemCountChange
)
PrimaryTextButton(text = stringResource(id = R.string.click_me), onClick = onButtonClick)
}
}
@Composable
fun ItemCountScreen(onButtonClick: (String) -> Unit) {
var state by remember {
mutableStateOf(ItemCountScreenState())
}
ItemCountScreenContent(state, {
state = state.copy(itemCount = it)
}, {
onButtonClick(state.itemCount)
})
}
컴포즈에서 화면 내비게이션 라이브러리를 사용하면 화면 이동을 할 수 있다. URL 기반이다.
implementation(libs.androidx.navigation.compose)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
FirstComposeTheme {
Surface(color = MaterialTheme.colorScheme.background) {
val navController = rememberNavController()
Column(modifier = Modifier.padding(16.dp)) {
MyApp(navController)
}
}
}
}
}
}
@Composable
fun MyApp(navController: NavHostController) {
NavHost(navController = navController,
startDestination = "itemCountScreen") {
composable("itemCountScreen") {
ItemCountScreen {
navController.navigate("itemScreen/?itemCount=$it")
}
}
composable(
"itemScreen/?itemCount={itemCount}",
arguments = listOf(navArgument("itemCount") {
type = NavType.StringType
})
) {
ItemScreen(
it.arguments?.getString("itemCount").orEmpty()
)
}
}
}
이상적인 상황은 액티비티가 적거나 하나인 상태로 모든 화면을 컴포즈로 개발하는 것이다. 기존 프로젝트라면 기존 뷰가 컴포즈 환경에서 빌드될 수 있도록 뷰 계층 구조의 맨 아래에서부터 시작해야 한다.
용이한 마이그레이션을 위해 XML 레이아웃에서 ComposeView 를 사용할 수 있는 기능을 제공한다.
<androidx.compose.ui.platform.ComposeView
android:id="@+id/compose_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
class MyFragment: Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
return inflater.inflate(
R.layout.my_fragment_layout, container).apply {
findViewById<ComposeView>(R.id.compose_view).apply {
setViewCompositionStrategy(
ViewCompositionStragy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme {
Text("My Text")
}
}
}
}
}
}
Fragment 가 XML 레이아웃을 인플레이트 하고 있다. ComposeView 가 프래그먼트 소멸 시점에 맞게 컴포즈 콘텐츠도 소멸되도록 명시하고 있다. 이로 인해 메모리 누수를 방지한다. 콘텐츠는 Text 이다.
반대도 가능하다. AndroidView 를 사용하면 된다.
@Compose
fun MyCustomisedElement(text: String) {
AndroidView(factory = { context ->
TextView(context).apply {
this.text = text
}
})
}
context 로 서비스 시작, 토스트 표시 등이 가능하다.
컴포즈도 기존 명령형 접근법에서 사용하던 여러 라이브러리를 사용할 수 있다.
@Compose
fun MyScreen(viewModel: MyViewModel = viewModel()) {
Text(text = viewModel.myText)
}
@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
viewModel.myLiveData.observeAsState()?.let {
myLiveDataText -> Text(text = myLiveDataText)
}
viewModel.myObservable.subscribeAsState()?.let {
myObservableText -> Text(text = myObservableText)
}
viewModel.myFlow.collectAsState()?.let {
myFlowText -> Text(text = myFlowText)
}
}
implementation(libs.androidx.hilt.navigation.compose)
[versions]
...
hiltNavigationCompose = "1.0.0"
[libraries]
...
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }