[Android] Jetpack Compose 기초 - 2

안세홍·2024년 8월 28일
post-thumbnail

Compose에서의 상태

이 섹션에서는 화면에 약간의 상호작용을 추가합니다. 지금까지는 정적 레이아웃을 만들었지만, 이제는 사용자 변경사항에 반응하여 화면과 상호작용할 수 있게 합니다.

버튼을 클릭할 수 있게 만드는 방법과 항목의 크기를 조절하는 방법을 알아보기 전에 각 항목이 펼쳐진 상태인지를 가리키는 값을 어딘가에 저장해야 합니다. 이 값을 항목의 상태라고 합니다. 인사말마다 이러한 값 중 하나가 필요하므로 이 값의 논리적 위치는 Greeting 컴포저블에 있습니다. 이 불리언 값 expanded 와 이 값이 코드에서 사용되는 방식을 살펴보세요.

// Don't copy over
@Composable
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")
            }
        }
    }
}

onClick 작업과 동적 버튼 텍스트도 추가했습니다. 이에 관한 설명은 나중에 자세히 다룹니다.

하지만, 이 작업은 예상대로 작동하지 않습니다. expanded 변수에 다른 값을 설정해도 Compose에서 이 값을 상태 변경으로 감지하지 않으므로 아무 일도 일어나지 않습니다.

이 변수를 변경했을 때 리컴포지션을 트리거하지 않는 이유는 이 변수를 Compose에서 추적하고 있지 않기 때문입니다. 또한, Greeting이 호출될 때마다 변수가 거짓으로 재설정됩니다.

컴포저블에 내부 상태를 추가하려면 mutableStateOf 함수를 사용하면 됩니다. 이 함수를 사용하면 Compose가 이 State를 읽는 함수를 재구성합니다.

import androidx.compose.runtime.mutableStateOf
// ...

// Don't copy over
@Composable
fun Greeting() {
    val expanded = mutableStateOf(false) // Don't do this!
}

하지만, 컴포저블 내의 변수에 mutableStateOf를 할당하기만 할 수는 없습니다. 앞에서 설명한 것처럼 false 값을 가진 변경 가능한 새 상태로 상태를 재설정하여 컴포저블을 다시 호출하는 때는 언제든지 리컴포지션이 일어날 수 있습니다.

여러 리컴포지션 간에 상태를 유지하려면 remember를 사용하여 변경 가능한 상태를 기억해야 합니다.

import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
// ...

@Composable
fun Greeting(...) {
    val expanded = remember { mutableStateOf(false) }
    // ...
}

remember는 리컴포지션을 방지하는 데 사용되므로 상태가 재설정되지 않습니다.

화면의 서로 다른 부분에서 동일한 컴포저블을 호출하는 경우 자체 상태 버전을 가진 UI 요소를 만듭니다. 내부 상태는 클래스의 비공개 변수로 보면 됩니다.

구성 가능한 함수는 상태를 자동으로 '구독'합니다. 상태가 변경되면 이러한 필드를 읽는 컴포저블이 재구성되어 업데이트를 표시합니다.

상태 변경 및 상태 변경사항에 반응

상태를 변경하기 위해 ButtononClick이라는 매개변수를 사용한다고 알고 있을 수도 있지만, 값을 사용하지 않고 함수를 사용합니다.

작업에 람다 표현식을 할당하여 클릭 시 실행할 작업을 정의할 수 있습니다. 예를 들어, 펼침 상태의 값을 전환하고 값에 따라 다른 텍스트를 표시해 보겠습니다.

ElevatedButton(
    onClick = { expanded.value = !expanded.value },
) {
   Text(if (expanded.value) "Show less" else "Show more")
}

앱을 대화형 모드로 실행하여 동작을 확인합니다.

버튼을 클릭하면 expanded가 전환되어 버튼 내부의 텍스트 리컴포지션을 트리거합니다. 각 Greeting은 서로 다른 UI 요소에 속하기 때문에 자체적으로 펼쳐진 상태를 유지합니다.

이 지점까지의 코드는 다음과 같습니다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val expanded = remember { mutableStateOf(false) }
    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.value = !expanded.value }
            ) {
                Text(if (expanded.value) "Show less" else "Show more")
            }
        }
    }
}

항목 펼치기

이제 실제로 요청을 받은 경우 항목을 펼쳐 보겠습니다. 상태에 따라 달라지는 추가 변수를 추가합니다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {

    val expanded = remember { mutableStateOf(false) }

    val extraPadding = if (expanded.value) 48.dp else 0.dp
// ...

extraPadding은 간단한 계산을 실행하므로 리컴포지션에 대비하여 이 값을 기억할 필요가 없습니다.

따라서, 이제 Column에 새로운 패딩 수정자를 적용할 수 있습니다.

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    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")
            }
        }
    }
}

에뮬레이터나 대화형 모드에서 실행하는 경우 각 항목이 독립적으로 펼쳐질 수 있어야 합니다.

상태 호이스팅

구성 가능한 함수에서 여러 함수가 읽거나 수정하는 상태는 공통의 상위 항목에 위치해야 합니다. 이 프로세스를 상태 호이스팅이라고 합니다. 호이스팅이란 들어 올린다 또는 끌어올린다라는 의미입니다.

상태를 호이스팅할 수 있게 만들면 상태가 중복되지 않고 버그가 발생하는 것을 방지할 수 있으며 컴포저블을 재사용할 수 있고 훨씬 쉽게 테스트할 수 있습니다. 이에 반하여, 컴포저블의 상위 요소에서 제어할 필요가 없는 상태는 호이스팅되면 안 됩니다. 정보 소스는 상태를 생성하고 관리하는 대상에 속합니다.

예를 들어, 앱의 온보딩 화면을 만들어 보겠습니다.

다음 코드를 MainActivity.kt에 추가합니다.

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
// ...

@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),
            onClick = { shouldShowOnboarding = false }
        ) {
            Text("Continue")
        }
    }
}

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    AndroidComposeStudyTheme {
        OnboardingScreen()
    }
}

이 코드에는 여러 가지 새로운 기능이 포함되어 있습니다.

  • OnboardingScreen이라는 새 컴포저블과 새 미리보기를 추가했습니다. 프로젝트를 빌드하면 동시에 여러 개의 미리보기가 있을 수 있습니다. 또한, 콘텐츠가 정확하게 정렬되는지 확인할 수 있도록 고정된 높이를 추가했습니다.
  • 화면 중앙에 콘텐츠를 표시할 수 있도록 Column을 구성할 수 있습니다.
  • shouldShowOnboarding= 대신 by 키워드를 사용하고 있습니다. 이 키워드는 매번 .value를 입력할 필요가 없도록 해주는 속성 위임입니다.
  • 버튼을 클릭하면 shouldShowOnboardingfalse로 설정되지만, 아직 어디에서도 이 상태를 읽지 않습니다.
    이제 앱에 이 새로운 온보딩 화면을 추가할 수 있습니다. 시작 시 이 화면을 표시하고 사용자가 'Continue'를 누르면 숨깁니다.

Compose에서는 UI 요소를 숨기지 않습니다. 대신, 컴포지션에 UI 요소를 추가하지 않으므로 Compose가 생성하는 UI 트리에 추가되지 않습니다. 간단한 조건부 Kotlin 로직을 사용하여 이 작업을 실행합니다. 예를 들어, 온보딩 화면이나 인사말 목록을 표시하려면 다음과 같이 합니다.

// Don't copy yet
@Composable
fun MyApp(modifier: Modifier = Modifier) {
    Surface(modifier) {
        if (shouldShowOnboarding) { // Where does this come from?
            OnboardingScreen()
        } else {
            Greetings()
        }
    }
}

하지만, shouldShowOnboarding에 액세스할 수 없습니다. OnboardingScreen에서 만든 상태를 MyApp 컴포저블과 공유해야 합니다.

여기서는 상태 값을 상위 요소와 공유하는 대신 상태를 호이스팅합니다. 즉, 상태 값에 액세스해야 하는 공통 상위 요소로 상태 값을 이동하기만 하면 됩니다.

먼저 MyApp의 콘텐츠를 Greetings라는 새 컴포저블로 이동합니다. 대신 Greetings 메서드를 호출하도록 미리보기를 조정합니다.

@Composable
fun MyApp(modifier: Modifier = Modifier) {
     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)
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingsPreview() {
    AndroidComposeStudyTheme {
        Greetings()
    }
}

새로운 최상위 MyApp 컴포저블의 미리보기를 추가하여 동작을 테스트할 수 있습니다.

@Preview
@Composable
fun MyAppPreview() {
    AndroidComposeStudyTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

이제 MyApp에 다른 화면을 표시하는 로직을 추가하고 상태를 호이스팅합니다.

@Composable
fun MyApp(modifier: Modifier = Modifier) {

    var shouldShowOnboarding by remember { mutableStateOf(true) }

    Surface(modifier) {
        if (shouldShowOnboarding) {
            OnboardingScreen(/* TODO */)
        } else {
            Greetings()
        }
    }
}

또한, shouldShowOnboarding을 온보딩 화면과 공유해야 하지만, 직접 전달하지는 않습니다. OnboardingScreen이 상태를 변경하도록 하는 대신 사용자가 Continue 버튼을 클릭했을 때 앱에 알리도록 하는 것이 더 좋습니다.

이벤트는 어떻게 전달할까요? 아래로 콜백을 전달합니다. 콜백은 다른 함수에 인수로 전달되는 함수로 이벤트가 발생하면 실행됩니다.

MyApp의 상태를 변경할 수 있도록 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")
        }
    }

}

이 방법은 상태가 아닌 함수를 OnboardingScreen에 전달하는 방식으로 이 컴포저블의 재사용 가능성을 높이고 다른 컴포저블이 상태를 변경하지 않도록 보호하고 있습니다. 일반적으로 이 방식은 작업을 간단하게 유지합니다. 다음은 이제 OnboardingScreen을 호출하기 위해 온보딩 미리보기를 어떻게 수정해야 하는지 보여주는 좋은 예입니다.

@Preview(showBackground = true, widthDp = 320, heightDp = 320)
@Composable
fun OnboardingPreview() {
    AndroidComposeStudyTheme {
        OnboardingScreen(onContinueClicked = {}) // Do nothing on click.
    }
}

onContinueClicked를 빈 람다 표현식에 할당하는 것은 '아무 작업도 하지 않음'을 의미합니다. 이는 미리보기를 위해 완벽합니다.

이후 완성된 gif

MyApp 컴포저블에서 매번 값을 사용하지 않도록 by 속성 위임을 처음으로 사용했습니다. expanded 속성의 Greeting 컴포저블에서도 = 대신 by를 사용해 보겠습니다. expandedval에서 var로 변경해야 합니다.

지금까지의 전체 코드는 다음과 같습니다.

package com.example.androidcomposestudy

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.androidcomposestudy.ui.theme.AndroidComposeStudyTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AndroidComposeStudyTheme {
                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() {
    AndroidComposeStudyTheme {
        OnboardingScreen(onContinueClicked = {})
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {

    var expanded by remember { mutableStateOf(false) }

    val extraPadding = if (expanded) 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 = !expanded }
            ) {
                Text(if (expanded) "Show less" else "Show more")
            }
        }
    }
}

@Preview(showBackground = true, widthDp = 320)
@Composable
fun GreetingPreview() {
    AndroidComposeStudyTheme {
        Greetings()
    }
}

@Preview
@Composable
fun MyAppPreview() {
    AndroidComposeStudyTheme {
        MyApp(Modifier.fillMaxSize())
    }
}

성능 지연 목록 만들기

이제 이름을 더 현실적으로 나열해 보겠습니다. 지금까지 Column에 두 개의 인사말을 표시했습니다. 하지만 수천 개의 인사말을 처리할 수 있을까요?

목록 크기를 설정하고 람다에 포함된 값으로 목록을 채우도록 허용하는 다른 목록 생성자를 사용하기 위해 Greetings 매개변수의 기본 목록 값을 변경합니다(여기서 $it은 목록 색인을 나타냄).

names: List<String> = List(1000) { "$it" }

이렇게 하면 화면에 맞지 않는 인사말을 포함하여 1,000개의 인사말이 생성됩니다. 이는 분명히 성능 기준에 맞지는 않습니다. 에뮬레이터에서 실행해 볼 수 있습니다(경고: 이 코드로 인해 에뮬레이터가 중단될 수도 있음).

스크롤이 가능한 열을 표시하기 위해 LazyColumn을 사용합니다. LazyColumn은 화면에 보이는 항목만 렌더링하므로 항목이 많은 목록을 렌더링할 때 성능이 향상됩니다.

참고: LazyColumnLazyRowAndroid 뷰의 RecyclerView와 동일합니다.

기본적인 사용법으로 LazyColumn API는 범위 내에서 items 요소를 제공하며, 여기서 로직을 렌더링하는 개별 항목은 다음과 같이 작성됩니다.

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
// ...

@Composable
private fun Greetings(
    modifier: Modifier = Modifier,
    names: List<String> = List(1000) { "$it" }
) {
    LazyColumn(modifier = modifier.padding(vertical = 4.dp)) {
        items(items = names) { name ->
            Greeting(name = name)
        }
    }
}

상태 유지

앱에는 두 가지 문제가 있습니다.

온보딩 화면 상태 유지

기기에서 앱을 실행하고 버튼을 클릭한 다음 회전하면 온보딩 화면이 다시 표시됩니다. remember 함수는 컴포저블이 컴포지션에 유지되는 동안에만 작동합니다. 기기를 회전하면 전체 활동이 다시 시작되므로 모든 상태가 손실됩니다. 이 현상은 구성이 변경되거나 프로세스가 중단될 때도 발생합니다.

remember를 사용하는 대신 rememberSaveable을 사용하면 됩니다. 이 함수는 구성 변경(예: 회전)과 프로세스 중단에도 각 상태를 저장합니다.

이제 shouldShowOnboarding에서 rememberrememberSaveable로 교체하여 사용합니다.

    import androidx.compose.runtime.saveable.rememberSaveable
    // ...

    var shouldShowOnboarding by rememberSaveable { mutableStateOf(true) }

실행 또는 회전하거나 어두운 모드로 변경 또는 프로세스를 종료해 봅니다. 이전에 앱을 종료하지 않았다면 온보딩 화면이 표시되지 않습니다.

목록 항목의 펼쳐진 상태 유지
목록 항목을 펼친 다음 항목이 보이지 않을 때까지 목록을 스크롤하거나, 기기를 회전한 다음 펼쳐진 항목으로 돌아가면 이제 항목이 초기 상태로 돌아온 것을 확인할 수 있습니다.

이 문제의 해결 방법은 펼쳐진 상태에도 rememberSaveable을 사용하는 것입니다.

   var expanded by rememberSaveable { mutableStateOf(false) }

추가적으로 안드로이드 공식 문서에 애니메이션 적용에 대한 설명도 나와있지만 이 부분은 따로 공부하면서 정리해보려고 합니다.

참고 - https://developer.android.com/codelabs/jetpack-compose-basics

profile
나만의 개발 일기

0개의 댓글