Developers 홈페이지에 있는 Jetpack Compose Essentials 코스의 4번째 챕터인 코드랩 Write your first Compose app의 7,8번을 정리한 글입니다.
위와 같이 버튼으로 사용자와의 상호작용을 추가해보자.
상호작용을 하려면 UI의 상태(expanded 여부)를 나타내는 변수가 필요하다. 상태 변수를 어딘가에 저장해야 하는데, 각 Greeting마다 필요하므로 Greeting 함수가 그 위치로 적절해보인다.
// 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")
}
}
}
}
하지만 이 코드는 기대처럼 동작하지 않는다. 왜냐면 Compose가 expanded 상태 값의 변화를 알아채지 못하기 때문이다.
Compose는 컴포즈 함수 내 상태 값이 변경되면 변경된 데이터로 해당 함수를 재호출함으로써 해당 UI 요소를 다시 그리는데, 이것을 recomposition이라고 한다.
Compose는 변경된 상태 값에 영향을 받는 컴포즈 함수들만 재호출(recomposition)하여, 다른 관련 없는 컴포넌트들은 다시 그려지지 않도록 한다.
State를 사용하면 Compose 시스템이 컴포저블 내부의 상태 값 변경을 감지하도록 만들 수 있다.
State는 값을 담는 data holder 인터페이스이다.
@Composable
fun Greeting() {
val expanded = mutableStateOf(false) // Don't do this!
}
이러면 컴포즈 함수(Greeting)가 자동으로 State에 구독(subscribed)돼서, State 값이 변경될 때 UI를 업데이트하기 위해 등록된 함수가 재호출된다.
하지만 위 코드도 원하는 동작을 하지 않는다. 왜냐면 재호출됐을 때 항상 false로 초기화되기 때문이다.
remember를 사용하면 recomposition 되더라도 같은 State 인스턴스를 기억해서 참조하게 만들 수 있다.
@Composable
fun Greeting() {
val expanded = remember { mutableStateOf(false) }
...
}
참고로, 같은 컴포즈 함수더라도 다른 위치에서 호출되면 별개의 UI 인스턴스로 동작하며, 따라서 별개의 State 인스턴스를 참조한다.
버튼 클릭시 State 값을 변경하여 UI 업데이트를 유도하자.
Button은 onClick 파라미터에 클릭시 실행시킬 함수를 입력 받는다. 우리는 이것을 보통 아래처럼 람다로 전달한다.
ElevatedButton(
onClick = { expanded.value = !expanded.value },
) {
Text(if (expanded.value) "Show less" else "Show more")
}
지금까지 코드를 적용한 결과는 다음과 같다.
버튼을 눌렀을 때 영역을 펼쳐보자. 상황에 따른 패딩을 표현할 변수(extraPadding)가 필요하다.
@Composable
private fun Greeting(name: String) {
val expanded = remember { mutableStateOf(false) }
val extraPadding = if (expanded.value) 48.dp else 0.dp
...
패딩에는 state를 사용하지 않고 있다. 컴포넌트를 그릴 때 처음 한 번만 사용될 값이기 때문이다.
@Composable
private fun Greeting(name: String) {
// remember: recomposition 대응
// State: 값 변경 관찰
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) // extraPadding 적용
) {
Text(text = "Hello, ")
Text(text = name)
}
ElevatedButton(
onClick = { expanded.value = !expanded.value }
) {
Text(if (expanded.value) "Show less" else "Show more")
}
}
}
}
다수의 컴포즈 함수가 공유해야 하는 state는 그들의 공통 조상 함수에서 관리하는 것이 좋다. 이러한 작업을 state hoisting이라고 한다.
hoisting이란 올리다, 상승시키다의 의미를 갖는다.
state hoisting은 다음의 장점을 가진다.
아래와 같은 온보딩 화면을 구현하며, state hoisting의 필요성, 과정, 장점에 대해 더 자세히 알아보자.
위 화면을 구현하기 위해 아래와 같은 코드를 생각해볼 수 있다.
@Composable
fun OnboardingScreen(modifier: Modifier = Modifier) {
// TODO: This state should be hoisted
var shouldShowOnboarding by remember { mutableStateOf(true) }
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Welcome to the Basics Codelab!")
Button(
modifier = Modifier.padding(vertical = 24.dp),
// 프로퍼티 위임을 통해 .value 생략
onClick = { shouldShowOnboarding = false }
) {
Text("Continue")
}
}
}
@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
BasicsCodelabTheme {
OnboardingScreen()
}
}
위 코드에서 주목해야할 점은 다음과 같다.
우리는 continue 버튼을 클릭시 이 화면을 없애고 이전에 만들었던 Greetings가 보이도록 하고 싶다.
Compose에서는 UI 컴포넌트를 숨기지 않고, UI 계층에서 아예 제거한다. 그리고 이것은 State 값의 변경으로 Recomposition을 유도하고, 조건문 안의 State 값에 따라 그 함수가 호출되지 않도록 함으로써 가능하다.
그것을 구현한 것이 아래의 코드이다.
// Don't copy yet
@Composable
fun MyApp(modifier: Modifier = Modifier) {
Surface(modifier) {
if (shouldShowOnboarding) { // Where does this come from?
OnboardingScreen()
} else {
Greetings()
}
}
}
@Composable
private fun Greetings(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(modifier = modifier.padding(vertical = 4.dp)) {
for (name in names) {
Greeting(name = name)
}
}
}
shouldShowOnboarding 값의 변경으로 recomposition될 때, MyApp()에서 OnboardingScreen()은 아예 호출되지 않는다.
하지만 shouldShowOnboarding는 현재 OnboardingScreen 함수에만 있으므로 두 함수가 공유하도록 해야 한다.
여기서 단순히 변수를 공유하지 않고, hoisting할 것이다. 즉, 아래처럼 공통 조상인 MyApp으로 올릴 것이다.
@Composable
fun MyApp(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(/* TODO */)
} else {
Greetings()
}
}
}
이제 OnboardingScreen()에 공유해야 하는데, 변수를 직접 전달하지 않을 것이다.
직접 공유보다 OnboardingScreen()에서 버튼이 클릭됐을 때 MyApp으로 이벤트를 알리는 것이 좋다. 그리고 이것은 콜백 함수를 전달함으로써 가능하다.
즉, OnboardingScreen()이 onContinueClicked: () -> Unit과 같은 콜백 함수를 파라미터로 갖도록 만드는 것이다. 왜 굳이 이렇게 하는지는 곧 설명한다.
이것이 반영된 두 함수의 코드는 다음과 같다.
@Composable
fun MyApp(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
} else {
Greetings()
}
}
}
@Composable
fun OnboardingScreen(
onContinueClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Welcome to the Basics Codelab!")
Button(
modifier = Modifier
.padding(vertical = 24.dp),
onClick = onContinueClicked
) {
Text("Continue")
}
}
}
State 변수 대신 콜백 함수로 상태를 공유하면 3가지 장점이 생긴다.
@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
BasicsCodelabTheme {
OnboardingScreen(onContinueClicked = {}) // Do nothing on click.
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
BasicsCodelabTheme {
MyApp(modifier = Modifier.fillMaxSize())
}
}
}
}
@Composable
fun MyApp(modifier: Modifier = Modifier) {
var shouldShowOnboarding by remember { mutableStateOf(true) }
Surface(modifier) {
if (shouldShowOnboarding) {
OnboardingScreen(onContinueClicked = { shouldShowOnboarding = false })
} else {
Greetings()
}
}
}
@Composable
fun OnboardingScreen(
onContinueClicked: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Welcome to the Basics Codelab!")
Button(
modifier = Modifier.padding(vertical = 24.dp),
onClick = onContinueClicked
) {
Text("Continue")
}
}
}
@Composable
private fun Greetings(
modifier: Modifier = Modifier,
names: List<String> = listOf("World", "Compose")
) {
Column(modifier = modifier.padding(vertical = 4.dp)) {
for (name in names) {
Greeting(name = name)
}
}
}
@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
BasicsCodelabTheme {
OnboardingScreen(onContinueClicked = {})
}
}
@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")
}
}
}
}
@Preview(showBackground = true, widthDp = 320)
@Composable
fun DefaultPreview() {
BasicsCodelabTheme {
Greetings()
}
}
@Preview
@Composable
fun MyAppPreview() {
BasicsCodelabTheme {
MyApp(Modifier.fillMaxSize())
}
}