지난 섹션에서 어떻게 컴포저블 함수들이 메모리를 갖는지 배웠다. 이제 그 메모리를 사용하여 컴포저블에 상태(state)를 추가 해보도록 하자.

Todo input (state:expanded(확장됨))
Todo input (state:collapsed(축소됨))

디자이너가 단정치 못한 디자인에서 포스트 머테리얼로 전환했다. todo 입력에 대한 새로운 디자인은 접을수 있는 헤더로써 같은 공간을 차지한다. 그리고 확장(expanded) 및 축소(collapsed) 두개의 상태를 갖는다. 텍스트가 비어있지 않다면 확장된 상태를 볼 수 있다.

이걸 만드려면, 우선 텍스트와 버튼을 만든 뒤, 자동숨김 아이콘을 추가하는 방법을 살펴보자.

UI에서 텍스트를 수정하는 것은 stateful하다. 사용자가 문자를 입력할 때마다 또는 선택 항목을 변경할 때에도 현재 표시된 텍스트를 업데이트한다. 안드로이드 View 시스템에서, state는 EditText 내부에 있고 onTextChanged 리스너를 통해 노출되었다. 하지만 컴포저는 단방향 데이터 흐름에 맞게 설계되어 이런 방식은 적합하지 않다.

TextField는 컴포저블 함수로 머테리얼의 EditText와 동등하다.

컴포즈의 TextField는 stateless한 컴포저블이다. todo 목록의 변경사항을 보여주는 TodoScreen과 같다. TextField는 지시한 내용을 표시하고, 사용자가 입력할 때 이벤트를 발생시킨다.

내장된 컴포저블들은 단방향 데이터 흐름에 맞게 설계되었다.

대부분의 내장 컴포저블들은 각 API에 대해 적어도 하나 이상의 stateless한 버전을 제공한다. View 시스템과 비교해서, 내장된 컴포저블은 수정 가능한 텍스트와 같은 상태 저장 UI에 대해 내부 상태가 없는 옵션을 제공한다. 이런점은 애플리케이션과 컴포저블 컴포넌트 사이에서 state의 중복을 막아준다. 예를 들면, 컴포저블에서 Checkbox에 대한 상태를 끌어올려 중복된 state없이 서버 기반의 API에 의존하도록 할 수 있다.

Stateful 및 Stateless

Stateful은 상태가 있고, Stateless는 상태가 없는 것을 의미한다.

컴포즈에서는 remember를 사용하여 객체를 저장하는 컴포저블은 stateful하다고 말할 수 있다. stateful한 컴포저블은 호출자가 상태를 제어 및 직접 관리하지 않아도 상태를 사용할 수 있는 경우에 유용하다. 그러나 내부 상태를 갖는 컴포저블은 재사용 가능성이 적고 테스트하기가 더 어려운 경향이 있다.

Stateless는 상태를 갖지 않는 컴포저블이다. Stateless를 만드는 한가지 방법은 State Hoisting을 사용하는 것이다.

재사용 가능한 컴포저블을 개발할 때 동일한 컴포저블에 대해 stateful 버전과 stateless 버전을 모두 노출해야 하는 경우가 있다. stateful 버전은 상태를 고려하지 않는 호출자에게 편리하고, statelss는 상테를 제어하거나 끌어올려야 하는 호출자에게 필요하다.

Stateful한 TextField 컴포저블 만들기

컴포즈 내 상태에 대해서 알아보기 위해, 수정가능한 TextField를 표현하는 stateful한 컴포넌트를 만들어 볼 것이다.

stateful 컴포저블은 시간에 따라 변경될 수 있는 상태를 소유하는 컴포저블을 말한다.

시작하기 위해 TodoScreen.kt를 열고 다음 함수를 추가하자.

// TodoScreen.kt
@Composable
fun TodoInputTextField(modifier: Modifier) {
   val (text, setText) = remember { mutableStateOf("") }
   TodoInputText(text, setText, modifier)
}

Warning: 이 텍스트 필드는 state를 hoist하지 않는다. 이 섹션 나중에 이 함수를 제거 할 예정이다.

이 함수는 remember를 사용하여 자기 자신에 메모리를 추가한 다음, 메모리 내에서 mutableStateof를 저장하여 관찰 가능한 state 홀더를 제공하는 컴포즈 내장 타입인 MutableState<String>을 생성한다.

mutableStateOf 또는 getValue가 정의되지 않았다는 컴파일러 오류가 발생하면 다음 import가 있는지 확인하자.

import androidx.compose.runtime.getValue

import androidx.compose.runtime.mutableStateOf

import androidx.compose.runtime.setValue

값과 setter 이벤트를 TodoInputText에 즉시 전달할 것이기 때문에, MutableState 객체를 getter와 setter로 분해한다.

mutableStateOf는 컴포즈에 내장되어 있으며 관찰가능한 상태 홀더인 MutableState<T>를 생성한다.

인터페이스 MutableState<T> : State<T> {
재정의 var 값 : T
}

value의 어떤 변경사항은 자동으로 이 state를 불러와 어떤 컴포저블 함수를 재구성한다.

컴포저블에서 3가지 방법으로 MutableState 객체를 선언할 수 있다.

1. val state = remember { mutableStateOf(default) }

2. val value by remember { mutableStateOf(default) }

3. val (value, setValue) = { mutableStateOf(default) }

구성(Composition)에서 State<T> (또는 다른 stateful한 객체)를 생성할 때, remember를 사용하는 것이 중요하다. 그렇지 않으면, 매번 새로 구성할 때마다 재초기화 된다.

MutableState<T>는 MutableLiveData<T> 비슷하지만, 런타임에 컴포즈와 통합된다. 관찰가능(observable)하기 때문에 업데이트될 때마다 compose에 알리므로 compose는 이를 읽는 모든 컴포저블들을 재구성할 수 있다.

TodoInputTextField내의 상태를 만들었다. 작동하는 모습을 보려면 TodoInputTextField와 Button을 표시하는 다른 TodoItemInput 컴포저블을 정의하자.

// TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   // onItemComplete은 이벤트로써 사용자에 의해 아이템이 완료 될 때 호출된다.
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputTextField(Modifier
               .weight(1f)
               .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = { /* todo */ },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically)
           )
       }
   }
}

TodoItemInput는 단지 onItemComplete 이벤트라는 하나의 매개변수를 갖는다. 사용자가 TodoItem을 완료할 때 이벤트가 트리거 된다. 람다를 전달하는 이 패턴은 컴포즈에서 커스텀 이벤트를 정의하는 주된 방법이다.

또한 프로젝트에 이미 정의되어 있는 백그라운드 TodoItemInputBackground에서 TodoItemInput을 호출하도록 TodoScreen 컴포저블을 업데이트 한다.

// TodoScreen.kt
@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   Column {
       // TodoItemInputBackground 및 TodoItem을 TodoScreen 상단에 추가한다.
       TodoItemInputBackground(elevate = true, modifier = Modifier.fillMaxWidth()) {
           TodoItemInput(onItemComplete = onAddItem)
       }
...

TodoItemInput 시험해보기

한 파일에서 주요한 UI 컴포저블을 정의 해왔다. 여기에 @Preview를 추가하는것은 좋은 아이디어다. 이는 컴포저블은 한데로 모아 볼 수 있도록 하고 또한 이 파일을 열어보는 사람들에게 손쉽게 미리보기를 제공한다.

TodoScreen.kt 에서 새로운 preview 함수는 하단에 추가해보자.

// TodoScreen.kt
@Preview
@Composable
fun PreviewTodoItemInput() = TodoItemInput(onItemComplete = { })

이제 interactive preview 또는 에뮬레이터에서 이 컴포저블을 실행시켜 격리된 이 컴포저블을 디버깅 하자.

그렇다면, 사용자가 수정할 수 있는 텍스트 필드가 화면에 나타나는 것을 확인할 수 있다. 문자 하나를 입력할 때마다, 사용자에게 표시되는 TextField를 업데이트 하는 재구성을 트리거하여 상태(state)가 업데이트 된다.

버튼을 클릭하여 아이템 추가하도록 만들기

이제 “Add”버튼이 실제로 TodoItem을 추가하도록 만들자. 이를 위해, TodoInputTextfFieldtext에 접근할 필요가 있다.

TodoItemInput 부분의 컴포지션 트리를 보면, TodoInputTextField의 안쪽에 text state를 저장하고 있는 것을 볼 수 있다.

TodoItemInput 컴포지션 트리(내장된 숨겨진 컴포저블)

이 구조는 onClicktext의 현재 값에 접근해야하기 때문에, onClick을 연결 할 수 없다. 우리가 하고자 하는 건 TodoItemInput에 있는 text state를 노출 시키고, 동시에 단방향 데이터 흐름을 사용하는 것이다.

Jetpack 컴포즈를 사용할 때, 단방향 데이터 흐름은 고수준 아키텍처 및 단일 컴포저블 설계에 모두 적용된다. 여기에서 우리는 이벤트는 항상 위로 흐르고 상태는 아래로 흐르도록 만들고 싶다.

이는 TodoItemInput에서의 상태는 아래로 흐르고, 이벤트를 위로 흐르는 것을 의미한다.

TodoItemInput에 대한 단방향 데이터 흐름 다이어그램

이를 위해, 하위 컴포저블인 TodoInputTextField를 상위의 TodoItemInput으로 state를 옮겨야 한다.

state hoisting과 함께 나타낸 TodoItemInput 컴포지션 트리

이 패턴은 state hoisting이라 불린다. 우리는 컴포저블로부터 state를 “끌어올려” stateless하게 만들 것이다. State hoisting은 주된 패턴으로 컴포즈에서 단방향 데이터 흐름 설계를 만든다.

State hoisting 은 state를 위로 옮겨 컴포넌트를 stateless하게 만드는 것을 의미한다.

컴포저블에 이를 적용할 때, 컴포저블에 두개의 매개변수를 종종 도입한다.

· value: T – 보여주기 위한 현재값

· onValueChange: (T) -> Unit – 값 변경을 요청하는 이벤트

state hoisting을 시작하려면, 컴포저블 내부 상태 T를 (value: T, onValueChange:(T)->Unit) 매개변수 쌍으로 리팩토링 할 수 있따.

TodoInputTextField를 수정하고 (value, onValueChange) 매개변수를 추가하는 것으로 상태를 끌어올리자.

// TodoScreen.kt
// 상태를 끌어올린 TodoInputTextField
@Composable
fun TodoInputTextField(text: String, onTextChange: (String) -> Unit, modifier: Modifier) {
   TodoInputText(text, onTextChange, modifier)
}

이 코드는 valueonValueChange 매개변수를 TodoInputTextField에 추가하였다. value 매개변수는 text 이고, onValueChange 매개변수는 onTextChange다.

그러면 state는 이제 끌어올려졌으니, TodoInputTextField에 있던 remember로 저장하던 상태는 제거할 수 있다.

이 방법으로 끌어올려진 state는 몇가지 중요한 속성을 갖게 된다.

  • Single source of truth – 중복된 상태를 갖는 것 대신 state를 옮김으로써, text에 대해 단일 source of truth임을 확신한다. 이는 버그 발생을 방지한다.
  • 캡슐화(Encapsulated) – 다른 컴포넌트들이 TodoItemInput에 이벤트를 보낼 때, 오직 TodoItemInput만 state를 수정할 수 있다. 이렇게 상태를 끌어올리면 비록 여러개의 컴포저블이 이 상태를 사용하더라도, 오직 하나의 컴포저블만이 stateful하게 된다.
  • 공유 가능성(Shareable)– 끌어올려진 state는 다양한 컴포저블과 함께 변경가능한 값으로써 공유된다. 여기에서는 TodoInputTextField 및 TodoEditButton 내에서 함께 state를 사용한다.
  • 가로채기(Interceptable) – TodoItemInput은 state가 변경되기 전에 이벤트를 무시하거나 수정할 수 있다. 예를 들면, TodoItemInput은 사용자가 입력 할 때 :emoji-codes:를 이모지로 변환 할 수 있다.
  • 분리하기(Decoupled) – TodoInputTextField에 대한 state는 아마 어딘가에 저장된다. 예를 들어, TodoInputTextField를 수정하지 않고 문자를 입력할 때 마다 업데이트 되는 상태를 Room 데이터베이스에 저장하도록 선택할 수 있다.

이제 TodoItemInput내 상태를 추가하고 이를 TodoInputTextField에 전달하자.

// TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputTextField(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = { /* todo */ },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically)
           )
       }
   }
}

우리는 이제 상태를 끌어 올렸고, 현재 텍스트 값을 사용하여 TodoEditButton의 동작을 주도할 수 있다. 콜백을 구현을 마무리 짓고, 텍스트가 비어있지 않을 때만 버튼을 활성화 하도록 하자.

// TodoScreen.kt
// TodoItemInput 수정하기
TodoEditButton(
   onClick = {
       onItemComplete(TodoItem(text)) // onItemComplete 이벤트를 위로 전달한다.
       setText("") // 내부 텍스트를 지운다.
   },
   text = "Add",
   modifier = Modifier.align(Alignment.CenterVertically),
   enabled = text.isNotBlank() // 텍스트가 비어있지 않을 때 활성화 한다.
)

다른 두개의 컴포저블에서 같은 state 변수인 text를 사용하고 있다. state를 끌어올림으로써 이렇게 state를 공유할 수 있게 되었다. 그리고 TodoItemInput만 stateful한 컴포저블로 만들었기 때문에 이런 관리가 가능해졌다.

다시 실행해보기

앱을 다시 시작 해보고 새로운 todo 아이템을 추가할 수 있음을 확인하자. 축하합니다 – 이제 컴포저블에 어떻게 state를 추가해야하는지 배웠고 어떻게 상태를 끌어올리는지도 배우게 되었다.

코드 정리

계속 진행하기 전에 TodoInputTextField함수 내 코드를 TodoItemInput 함수 내로 집어 넣자. state hoisting(상태 끌어올리기)를 알아보기 위해 이 섹션에서 추가했던 것 뿐이다. 코드랩에서 제공된 TodoInputText의 코드를 들여다 보면, 이 섹션에서 다루었던 패턴을 따라 state를 이미 끌어올린것을 볼 수 있다.

끝났으면 TodoItemInput은 다음과 같아야 한다.

// TodoScreen.kt
@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputText(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp)
           )
           TodoEditButton(
               onClick = {
                   onItemComplete(TodoItem(text))
                   setText("")
               },
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
   }
}

다음 섹션에서 우리는 계속해서 이 디자인을 만들고 아이콘을 추가할 것이다. 이 섹션에서 배운 도구를 사용하여 상태를 끌어올리고 단방향 데이터 흐름으로 대화형 UI를 만든다.

카테고리: Compose

2개의 댓글

이수균 · 2021년 11월 27일 9:14 오후

코드랩 진행하면서 너무 잘보고있습니다!😊 MutableState 객체 생성하는 1, 2, 3 방법에 코드가 번역되어 들어갔네요🤭

    Charlezz · 2021년 11월 29일 12:22 오후

    알려주셔서 감사합니다!!! 수정했습니다 🙂

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.