Google은 흑인 공동체를 위한 인종 간 평등을 진전시키기 위해 노력하고 있습니다. Google에서 어떤 노력을 하고 있는지 확인하세요.

상태 및 Jetpack Compose

앱의 상태는 시간이 지남에 따라 변할 수 있는 값입니다. 이는 매우 광범위한 정의로서 Room 데이터베이스부터 클래스 변수까지 모든 항목이 포함됩니다.

모든 Android 앱에서는 사용자에게 상태가 표시됩니다. 다음은 Android 앱 상태의 몇 가지 예입니다.

  • 네트워크 연결을 설정할 수 없을 때 표시되는 스낵바
  • 블로그 게시물 및 관련 댓글
  • 사용자가 클릭하면 버튼에서 재생되는 물결 애니메이션
  • 사용자가 이미지 위에 그릴 수 있는 스티커

Jetpack Compose를 사용하면 Android 앱에서 상태를 저장하고 사용하는 위치와 방법을 명시적으로 나타낼 수 있습니다.

UI 업데이트 루프 및 이벤트

Android 앱에서는 이벤트에 대한 응답으로 상태가 업데이트됩니다. 이벤트는 앱 외부에서 발생되는 입력입니다. 예를 들어 사용자가 OnClickListener를 호출하는 버튼이나 afterTextChanged를 호출하는 EditText, 새 값을 전송하는 가속도계를 탭하는 경우입니다.

모든 Android 앱에는 다음과 같은 모습의 핵심 UI 업데이트 루프가 있습니다.

Android 앱의 핵심 UI 업데이트 루프

  • 이벤트: 이벤트는 사용자 또는 프로그램의 다른 부분에 의해 생성됩니다.
  • 상태 업데이트: 이벤트 핸들러가 상태를 변경합니다.
  • 상태 표시: 새로운 상태를 표시하도록 UI가 업데이트됩니다.

Jetpack Compose에서는 상태와 이벤트가 별개입니다. 상태는 변경 가능한 값을 나타내고 이벤트는 문제가 발생했다는 알림을 나타냅니다.

상태를 이벤트와 분리하면 상태 표시 방법을 상태 저장 및 변경 방식과 분리할 수 있습니다.

Jetpack Compose의 단방향 데이터 흐름

Compose는 단방향 데이터 흐름으로 제작되었습니다. 이 데이터 흐름은 상태는 아래로 흐르고 이벤트는 위로 흐르도록 설계되었습니다.

그림 1. 단방향 데이터 흐름

단방향 데이터 흐름을 따르면 UI에 상태를 표시하는 컴포저블과 상태를 저장하고 변경하는 앱 부분을 서로 분리할 수 있습니다.

단방향 데이터 흐름을 사용하는 앱의 UI 업데이트 루프는 다음과 같습니다.

  • 이벤트: 이벤트는 일부 UI에 의해 생성되고 위로 전달됩니다.
  • 상태 업데이트: 이벤트 핸들러가 상태를 변경할 수 있습니다.
  • 상태 표시: 상태가 아래로 전달되고 UI가 새 상태를 관찰하여 표시합니다.

Jetpack Compose를 사용할 때 이 패턴을 따르면 몇 가지 이점이 있습니다.

  • 테스트 가능성: 상태와 상태를 표시하는 UI를 분리함으로써 이 두 개를 구분된 상태에서 더 쉽게 테스트할 수 있습니다.
  • 상태 캡슐화: 상태를 한 곳에서만 업데이트할 수 있으므로 일관되지 않은 상태(또는 버그)를 만들 가능성이 낮습니다.
  • UI 일관성: 관찰 가능한 상태 홀더를 사용함으로써 모든 상태 업데이트가 UI에 즉시 반영됩니다.

ViewModel 및 단방향 데이터 흐름

Android 아키텍처 구성요소에서 ViewModelLiveData를 사용하면 앱에 단방향 데이터 흐름을 적용할 수 있습니다.

Compose에서 ViewModel를 살펴보기 전에 Activity를 먼저 고려하세요. 이 구성요소는 "Hello, ${name}"을 표시하고 사용자에게 이름을 입력하도록 허용하는 단방향 데이터 흐름과 Android 뷰를 사용합니다.

ViewModel에서의 사용자 입력 예

ViewModelActivity를 사용하는 이 화면의 코드:

class HelloViewModel: ViewModel() {

   // LiveData holds state which is observed by the UI
   // (state flows down from ViewModel)
   private val _name = MutableLiveData("")
   val name: LiveData<String> = _name

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(newName: String) {
       _name.value = newName
   }
}

class HelloActivity : AppCompatActivity() {
   val helloViewModel by viewModels<HelloViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* … */
       // binding represents the activity layout, inflated with ViewBinding
       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString())
       }

       helloViewModel.name.observe(this) { name ->
           binding.helloText.text = "Hello, $name"
       }
   }
}

Android 아키텍처 구성요소를 사용하여 이 Activity에 단방향 데이터 흐름 설계가 도입되었습니다.

그림 2. Activity에서 ViewModel을 사용한 단방향 데이터 흐름

UI 업데이트 루프에서 단방향 데이터 흐름이 작동하는 방식을 확인하려면 이 Activity의 루프를 살펴보세요.

  1. 이벤트: 텍스트 입력이 변경되면 UI가 onNameChanged를 호출합니다.
  2. 상태 업데이트: onNameChanged가 처리를 진행하고 _name의 상태를 설정합니다.
  3. 상태 표시: name의 관찰자가 호출되고 UI에 새 상태가 표시됩니다.

ViewModel 및 Jetpack Compose

Jetpack Compose에서 이전 섹션의 Activity에서 한 방식과 동일하게 LiveDataViewModel을 사용하여 단방향 데이터 흐름을 구현할 수 있습니다.

다음은 HelloActivity와 동일한 화면의 코드로, Jetpack Compose에서 동일한 HelloViewModel을 사용하여 작성된 것입니다.

class HelloViewModel: ViewModel() {

   // LiveData holds state which is observed by the UI
   // (state flows down from ViewModel)
   private val _name = MutableLiveData("")
   val name: LiveData<String> = _name

   // onNameChanged is an event we're defining that the UI can invoke
   // (events flow up from UI)
   fun onNameChanged(newName: String) {
       _name.value = newName
   }
}

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
   // by default, viewModel() follows the Lifecycle as the Activity or Fragment
   // that calls HelloScreen(). This lifecycle can be modified by callers of HelloScreen.

   // name is the _current_ value of [helloViewModel.name]
   // with an initial value of ""
   val name: String by helloViewModel.name.observeAsState("")

   Column {
       Text(text = name)
       TextField(
           value = name,
           onValueChange = { helloViewModel.onNameChanged(it) },
           label = { Text("Name") }
       )
   }
}

HelloViewModelHelloScreen은 단방향 데이터 흐름의 설계를 따릅니다. 상태는 HelloViewModel에서 아래로 흐르고 이벤트는 HelloScreen에서 위로 흐릅니다.

view model과 hello screen 사이의 단방향 흐름

이 컴포저블의 UI 이벤트 루프를 살펴보겠습니다.

  1. 이벤트: 사용자가 문자를 입력하는 것에 대한 응답으로 onNameChanged가 호출됩니다.
  2. 상태 업데이트: onNameChanged가 처리를 진행하고 _name의 상태를 설정합니다.
  3. 상태 표시: name의 값이 변경됩니다. 이는 Compose에 의해 observeAsState에서 관찰됩니다. 그러면 HelloScreenname의 새 값에 따라 UI를 기술하도록 다시 실행(또는 재구성)됩니다.

ViewModelLiveData를 사용하여 Android에서 단방향 데이터 흐름을 빌드하는 방법에 관한 자세한 내용은 앱 아키텍처 가이드를 참조하세요.

스테이트리스(Stateless) 컴포저블

스테이트리스(Stateless) 컴포저블은 스스로 상태를 변경할 수 없는 컴포저블입니다. 스테이트리스(Stateless) 구성요소는 테스트하기가 쉽고 대체로 버그가 적으며 재사용 가능성이 더 높습니다.

컴포저블에 상태가 있는 경우 상태 끌어올리기를 통해 상태를 스테이트리스(Stateless)로 만들 수 있습니다. 상태 끌어올리기는 컴포저블의 내부 상태를 매개변수와 이벤트로 대체하여 상태를 컴포저블의 호출자로 옮기는 프로그래밍 패턴입니다.

상태 끌어올리기의 예를 보려면 HelloScreen에서 스테이트리스(Stateless) 컴포저블을 추출합니다.

@Composable
fun HelloScreen(helloViewModel: HelloViewModel = viewModel()) {
   // helloViewModel follows the Lifecycle as the the Activity or Fragment that calls this
   // composable function. This lifecycle can be modified by callers of HelloScreen.

   // name is the _current_ value of [helloViewModel.name]
   val name: String by helloViewModel.name.observeAsState("")

   HelloInput(name = name, onNameChange = { helloViewModel.onNameChanged(it) })
}

@Composable
fun HelloInput(
   /* state */ name: String,
   /* event */ onNameChange: (String) -> Unit
) {
   Column {
       Text(name)
       TextField(
           value = name,
           onValueChange = onNameChange,
           label = { Text("Name") }
       )
   }
}

HelloInput은 변경할 수 없는 String 매개변수의 형태로 상태에 액세스할 수 있습니다. 또한 상태 변경을 요청하고자 할 때 호출할 수 있는 이벤트 onNameChange에도 액세스할 수 있습니다.

람다는 컴포저블의 이벤트를 설명하는 가장 일반적인 방법입니다. 여기에서는 Kotlin의 함수 유형 구문 (String) -> Unit을 사용하여 String을 취하는 람다가 사용된 onNameChange 이벤트를 정의합니다. 이 이벤트는 상태가 이미 변경되었다는 의미가 아니라 컴포저블이 이벤트 핸들러에 상태를 변경하도록 요청 중에 있다는 의미이므로 onNameChange은 현재형 시제입니다.

HelloScreenname 상태를 직접 변경할 수 있는 최종 클래스 HelloViewModel에 종속되므로 스테이트풀(Stateful) 컴포저블입니다. HelloScreen 호출자가 name 상태의 업데이트를 제어할 수 있는 방법은 없습니다. HelloInput은 상태를 직접 변경할 수 없으므로 스테이트리스(Stateless) 컴포저블입니다.

HelloInput에서 상태를 끌어올리면 더 쉽게 컴포저블을 추론하고 여러 상황에서 재사용하며 테스트할 수 있습니다. HelloInput은 상태의 저장 방식과 분리됩니다. 분리된다는 것은 HelloViewModel을 수정하거나 교체할 경우 HelloInput의 구현 방식을 변경할 필요가 없다는 의미입니다.

상태 끌어올리기 프로세스를 통해 단방향 데이터 흐름을 스테이트리스(Stateless) 컴포저블로 확장할 수 있습니다. 이러한 컴포저블의 단방향 데이터 흐름 다이어그램에서는 추가 컴포저블이 상태와 상호작용하는 동안 상태는 아래로 이동하고, 이벤트는 위로 이동하도록 유지됩니다.

HelloInput, HelloScreen, HelloViewModel 간의 상태 및 이벤트 흐름

단방향 데이터 흐름과 상태 끌어올리기를 사용하면 스테이트리스(Stateless) 컴포저블이 시간에 따라 변하는 상태와 계속해서 상호작용할 수 있다는 점을 이해하는 것이 중요합니다.

이 기능의 작동 방식을 이해하려면 HelloInput의 UI 업데이트 루프를 살펴보세요.

  1. 이벤트: 사용자가 문자를 입력하는 것에 대한 응답으로 onNameChange가 호출됩니다.
  2. 상태 업데이트: HelloInput은 상태를 직접 수정할 수 없습니다. 호출자가 onNameChange 이벤트에 대한 응답으로 상태를 수정할지 선택할 수 있습니다. 여기에서는 호출자 HelloScreenHelloViewModel에서 onNameChanged를 호출하고 이로 인해 name 상태가 업데이트됩니다.
  3. 상태 표시: name의 값이 변하면 observeAsState로 인해 name가 업데이트되면서 HelloScreen이 다시 호출됩니다. 그러면 새 name 매개변수로 HelloInput이 다시 호출됩니다. 상태 변경에 대한 응답으로 컴포저블을 다시 호출하는 것을 리컴포지션이라고 합니다.

컴포지션 및 리컴포지션

컴포지션은 UI를 기술하는 역할을 하며 컴포저블을 실행하면 생성됩니다. 컴포지션은 UI를 기술하는 컴포저블의 트리 구조입니다.

초기 컴포지션 시 Jetpack Compose는 컴포지션에서 UI를 기술하기 위해 호출하는 컴포저블을 추적합니다. 그런 다음 앱 상태가 변경되면 Jetpack Compose은 리컴포지션을 예약합니다. 리컴포지션에서는 상태 변경에 대한 응답으로 변할 수 있는 컴포저블을 실행하고, Jetpack Compose는 변경사항을 반영하도록 컴포지션을 업데이트합니다.

컴포지션은 초기 컴포지션을 통해서만 생성되고 리컴포지션을 통해서만 업데이트될 수 있습니다. 컴포지션을 수정하는 유일한 방법은 리컴포지션을 통하는 것입니다.

초기 컴포지션 및 리컴포지션에 대한 자세한 내용은 Compose 이해를 참조하세요.

컴포저블의 상태

구성 가능한 함수는 remember 컴포저블을 사용하여 메모리에 단일 객체를 저장할 수 있습니다. remember에 의해 계산된 값은 초기 컴포지션 중에 컴포지션에 저장되고 저장된 값은 리컴포지션 중에 반환됩니다. remember는 변경 가능한 객체뿐만 아니라 변경할 수 없는 객체를 저장하는 데 사용할 수 있습니다.

remember를 사용하여 변경 불가능한 값 저장

텍스트 서식 지정을 계산하는 등 연산 비용이 많이 드는 UI 작업을 캐싱할 경우 변경할 수 없는 값을 저장할 수 있습니다. 기억된 값은 remember를 호출한 컴포저블과 함께 컴포지션에 저장됩니다.

@Composable
fun FancyText(text: String) {
    // by passing text as a parameter to remember, it will re-run the calculation on
    // recomposition if text has changed since the last recomposition
    val formattedText = remember(text) { computeTextFormatting(text) }
    …
}
그림 3. formattedText를 하위 요소로 갖는 FancyText의 컴포지션

remember를 사용하여 컴포저블의 내부 상태 만들기

remember를 사용하여 변경 가능 객체를 저장할 경우 컴포저블에 상태를 추가할 수 있습니다. 이 접근 방식은 단일 스테이트풀(Stateful) 컴포저블의 내부 상태를 만들 때도 사용할 수 있습니다.

컴포저블에 사용되는 변경 가능한 상태는 모두 관찰 가능한 상태로 두는 것이 좋습니다. 이렇게 하면 상태가 변할 때마다 Compose에서 자동으로 재구성됩니다. Compose는 관찰 가능한 유형 State<T>가 내장되어 있으며, 이는 런타임 시 Compose에 바로 통합됩니다.

컴포저블의 내부 상태를 보여주는 좋은 예는 사용자가 버튼을 클릭하면 접혔다가 펼쳐질 때 애니메이션되는 ExpandingCard입니다.

그림 4. 접혔다가 펼쳐질 때 애니메이션되는 ExpandedCard 컴포저블

이 컴포저블에는 expanded라는 중요한 상태가 하나 있습니다. expanded일 때는 컴포저블은 본문을 표시하고 collapsed일 때는 본문을 숨깁니다.

그림 5. expanded 상태를 하위 요소로 갖는 ExpandingCard의 컴포지션

mutableStateOf(initialValue)를 저장하여 상태 expanded를 컴포저블에 추가할 수 있습니다.

@Composable
fun ExpandingCard(title: String, body: String) {
   // expanded is "internal state" for ExpandingCard
   var expanded by remember { mutableStateOf(false)  }

   // describe the card for the current state of expanded
   Card {
       Column(
           Modifier
               .width(280.dp)
               .animateContentSize() // automatically animate size when it changes
               .padding(top = 16.dp, start = 16.dp, end = 16.dp)
       ) {
           Text(text = title)

           // content of the card depends on the current value of expanded
           if (expanded) {
               // TODO: show body & collapse icon
           } else {
               // TODO: show expand icon
           }
       }
   }

mutableStateOf는 관찰 가능한 MutableState<T>를 생성하고, 이는 런타임 시 Compose에 통합되는 관찰 가능 유형입니다.

interface MutableState<T> : State<T> {
   override var value: T
}

value가 변경되면 value를 읽는 구성 가능한 함수의 리컴포지션이 예약됩니다. ExpandingCard의 경우 expanded가 변경될 때마다 ExpandingCard가 재구성됩니다.

컴포저블에서 MutableState 객체를 선언하는 데는 세 가지 방법이 있습니다.

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

이러한 선언은 동일한 것이며 서로 다른 용도의 상태를 사용하기 위한 구문 설탕으로 제공됩니다. 작성 중인 컴포저블에서 가장 읽기 쉬운 코드를 생성하는 선언을 선택해야 합니다.

컴포저블의 내부 상태 값은 다른 컴포저블의 매개변수로 사용하거나, 심지어는 호출되는 컴포저블의 유형을 변경하는 데 사용할 수 있습니다. ExpandingCard에서는 if 구문이 expanded의 현재 값에 따라 카드의 콘텐츠를 변경합니다.

if (expanded) {
   // TODO: show body & collapse icon
} else {
   // TODO: show expand icon
}

컴포저블의 내부 상태 수정

상태는 컴포저블에서 이벤트에 의해 수정되어야 합니다. 이벤트에서 수정하는 대신 컴포저블을 실행할 때 상태를 수정하는 것은 컴포저블의 부작용에 해당하므로 피해야 합니다. Jetpack Compose의 부작용에 관한 자세한 내용은 Compose 이해를 참조하세요.

ExpandingCard 컴포저블을 완료하기 위해 body를 표시하고 expandedtrue이면 접기 버튼을 표시하고 expandedfalse이면 펼치기 버튼을 표시해 보겠습니다.

@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by remember { mutableStateOf(false)  }

   // describe the card for the current state of expanded
   Card {
       Column(
           Modifier
               .width(280.dp)
               .animateContentSize() // automatically animate size when it changes
               .padding(top = 16.dp, start = 16.dp, end = 16.dp)
       ) {
           Text(text = title)

           // content of the card depends on the current value of expanded
           if (expanded) {
               Text(text = body, Modifier.padding(top = 8.dp))
               // change expanded in response to click events
               IconButton(onClick = { expanded = false }, modifier = Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandLess)
               }
           } else {
               // change expanded in response to click events
               IconButton(onClick = { expanded = true }, modifier = Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

이 컴포저블에서는 onClick 이벤트에 대한 응답으로 상태가 수정됩니다. expanded속성 위임 구문에 var를 사용하기 때문에 onClick 콜백은 expanded를 직접 할당할 수 있습니다.

IconButton(onClick = { expanded = true }, /* … */) {
   // ...
}

이제 Compose의 내부 상태 수정 및 사용 방식을 알 수 있는 ExpandingCard의 UI 업데이트 루프를 설명할 수 있습니다.

  1. 이벤트: 사용자가 버튼 중 하나를 탭하는 것에 대한 응답으로 onClick이 호출됩니다.
  2. 상태 업데이트: 할당을 사용하는 onClick 리스너에서 expanded가 변경됩니다.
  3. 상태 표시: expandedState<Boolean>로 변경되었고 ExpandingCard가 이를 if(expanded) 라인에서 읽으므로 ExpandingCard가 재구성됩니다. 그런 다음 ExpandingCard는 화면에 expanded의 새 값을 기술합니다.

Jetpack Compose에서 다른 유형의 상태 사용

Jetpack Compose에서는 상태를 보존하기 위해 MutableState<T>를 사용할 필요가 없습니다. Jetpack Compose는 관찰 가능한 다른 유형을 지원합니다. Jetpack Compose에서 관찰 가능한 다른 유형을 읽으려면 상태를 State<T>로 변환해야 합니다. 그래야 상태가 변할 때 Jetpack Compose가 자동으로 재구성됩니다.

Compose에는 Android 앱에 사용되는 관찰 가능한 일반 유형에서 State<T>를 만들 수 있는 함수가 내장되어 있습니다.

앱에 관찰 가능한 맞춤 클래스를 사용하는 경우 관찰 가능한 다른 유형을 읽어오기 위한 Jetpack Compose용 확장 함수를 빌드할 수 있습니다. 이를 실행하는 방법의 예는 내장 구현을 참조하세요. 모든 변경사항을 수신하도록 Jetpack Compose에 허용하는 모든 객체를 State<T>로 변환하고 컴포저블을 통해 읽어올 수 있습니다.

수동으로 컴포지션을 트리거하는 invalidate를 사용하여 관찰 불가능한 상태 객체의 통합 레이어를 빌드할 수도 있습니다. 이 클래스는 관찰 불가능한 유형과 상호운용해야 하는 경우에는 반드시 예약해야 합니다. invalidate를 사용할 경우 실수하기가 쉬우며 관찰 가능한 상태 객체를 사용하는 동일한 코드보다 더 읽기 어려운 복잡한 코드를 만들기도 합니다.

UI 컴포저블에서 내부 상태 분리

마지막 섹션의 ExpandingCard에 내부 상태가 있습니다. 따라서 호출자는 상태를 제어할 수 없습니다. 즉, 예를 들어 펼침 상태에서 ExpandingCard를 시작하려는 경우 이를 실행할 방법이 없습니다. 또한 사용자는 또 다른 이벤트(예: 사용자가 Fab를 클릭하는 경우)에 대한 응답으로 카드를 펼칠 수도 없습니다. 또한 expanded 상태를 ViewModel로 옮기고자 하는 경우에도 이를 실행할 수 없습니다.

반면 ExpandingCard에 내부 상태를 사용하면 상태를 제어하거나 끌어올릴 필요가 없는 호출자의 경우 상태를 관리하지 않고 사용할 수 있습니다.

재사용 가능한 컴포저블을 개발할 때 동일한 컴포저블의 스테이트풀(Stateful) 버전과 스테이트리스(Stateless) 버전을 모두 노출하고자 할 수 있습니다. 스테이트풀(Stateful) 버전은 상태를 염두에 두지 않는 호출자에 편리하며, 스테이트리스(Stateless) 버전은 상태를 제어하거나 끌어올려야 하는 호출자에 필요합니다.

스테이트풀(Stateful)과 스테이트리스(Stateless) 인터페이스 형태로 모두 제공하려면 상태 끌어올리기를 사용하여 UI를 표시하는 스테이트리스(Stateless) 컴포저블을 추출하세요.

두 컴포저블은 서로 다른 매개변수를 사용하더라도 이름이 모두 ExpandingCard로 지정됩니다. UI를 내보내는 컴포저블의 이름 지정 규칙은 CapitalCase 명사로, 화면에서 컴포저블이 나타내는 내용을 설명합니다. 이 경우에는 둘 모두 ExpandingCard를 나타냅니다. 이러한 이름 지정 규칙은 TextFieldTextField에서와 같은 Compose 라이브러리 전체에 적용되었습니다.

다음은 스테이트풀(Stateful) 컴포저블과 스테이트리스(Stateless) 컴포저블로 분할된 ExpandingCard입니다.

// this stateful composable is only responsible for holding internal state
// and defers the UI to the stateless composable
@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by remember { mutableStateOf(false)  }
   ExpandingCard(
       title = title,
       body = body,
       expanded = expanded,
       onExpand = { expanded = true },
       onCollapse = { expanded = false }
   )
}

// this stateless composable is responsible for describing the UI based on the state
// passed to it and firing events in response to the buttons being pressed
@Composable
fun ExpandingCard(
   title: String,
   body: String,
   expanded: Boolean,
   onExpand: () -> Unit,
   onCollapse: () -> Unit
) {
   Card {
       Column(
           Modifier
               .width(280.dp)
               .animateContentSize() // automatically animate size when it changes
               .padding(top = 16.dp, start = 16.dp, end = 16.dp)
       ) {
           Text(title)
           if (expanded) {
               Spacer(Modifier.height(8.dp))
               Text(body)
               IconButton(onClick = onCollapse, Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandLess)
               }
           } else {
               IconButton(onClick = onExpand, Modifier.fillMaxWidth()) {
                   Icon(Icons.Default.ExpandMore)
               }
           }
       }
   }
}

Compose에서 상태 끌어올리기는 컴포저블을 스테이트리스(Stateless)로 만들기 위해 상태를 컴포저블의 호출자로 옮기는 패턴입니다. Jetpack Compose에서 상태를 끌어올리기 위한 일반적 패턴은 상태 변수를 다음 두 개의 매개변수로 바꾸는 것입니다.

  • value: T: 표시할 현재 값
  • onValueChange: (T) -> Unit: T가 제안된 새 값인 경우 값을 변경하도록 요청하는 이벤트

하지만 onValueChange로만 제한되지 않습니다. 컴포저블에 더 특정한 이벤트가 어울리는 경우 ExpandingCardonExpandonCollapse를 정의할 때와 같이 람다를 사용하여 그 이벤트를 정의해야 합니다.

이러한 방식으로 끌어올린 상태에는 중요한 속성이 몇 가지 있습니다.

  • 단일 소스 저장소: 상태를 복제하는 대신 옮겼기 때문에 expanded의 소스 저장소가 하나만 있습니다. 버그 방지에 도움이 됩니다.
  • 캡슐화됨: 스테이트풀(Stateful) ExpandingCard만 상태를 수정할 수 있습니다. 철저히 내부적 속성입니다.
  • 공유 가능함: 끌어올린 상태를 여러 컴포저블과 공유할 수 있습니다. 예를 들어 Card가 펼쳐질 때 Fab 버튼을 숨기려고 한다면 끌어올리기를 통해 그 작업이 가능합니다.
  • 가로채기 가능함: 스테이트리스(Stateless) ExpandingCard의 호출자는 상태를 변경하기 전에 이벤트를 무시할지 수정할지 결정할 수 있습니다.
  • 분리됨: 스테이트리스(Stateless) ExpandingCard의 상태는 어디에나 저장할 수 있습니다. 예를 들어 이제는 title, body, expandedViewModel로 옮길 수 있습니다.

이 방법으로 끌어올리기를 할 경우에도 단방향 데이터 흐름을 따르게 됩니다. 상태는 스테이트풀(Stateful) 컴포저블에서 아래로 전달되고 이벤트는 스테이트리스(Stateless) 컴포저블에서 위로 흐릅니다.

그림 6. 스테이트풀(Stateful)과 스테이트리스(Stateless) ExpandingCard의 단방향 데이터 흐름 다이어그램

내부 상태 및 구성 변경사항

컴포지션에서 remember에 의해 기억된 값은 잊혀졌다가 회전 같은 구성 변경 중에 다시 생성됩니다.

remember { mutableStateOf(false) }를 사용하면 스테이트풀(Stateful) ExpandingCard는 사용자가 휴대전화를 회전할 때마다 접히도록 재설정됩니다. 이 패턴은 저장된 인스턴스 상태를 대신 사용하여 수정할 수 있으며 그러면 구성 변경 시 상태가 자동으로 저장되고 복원됩니다.

@Composable
fun ExpandingCard(title: String, body: String) {
   var expanded by savedInstanceState { false }
   ExpandingCard(
       title = title,
       body = body,
       expanded = expanded,
       onExpand = { expanded = true },
       onCollapse = { expanded = false }
   )
}

구성 가능한 함수 savedInstanceState<T>는 구성 변경 시 자동으로 저장 및 복원되는 MutableState<T>를 반환합니다. 사용자가 구성 변경을 유지할 것으로 예상되는 모든 내부 상태에 이 함수를 사용해야 합니다.

자세히 알아보기

상태 및 Jetpack Compose에 관한 자세한 내용은 Jetpack Compose에서 상태 사용을 참조하세요.