Jetpack Compose에서 상태 사용

1. 소개

이 Codelab에서는 상태 및 Jetpack Compose에서 상태를 사용하고 조작하는 방법을 알아봅니다.

시작하기 전에 정확히 상태가 무엇인지 정의하는 것이 좋습니다. 기본적으로 애플리케이션의 상태는 시간이 지남에 따라 변할 수 있는 값입니다. 이는 매우 광범위한 정의로서 Room 데이터베이스부터 클래스 변수까지 모든 항목이 포함됩니다.

모든 Android 애플리케이션에서는 사용자에게 상태가 표시됩니다. 다음은 Android 애플리케이션 상태의 몇 가지 예시입니다.

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

이 Codelab에서는 Jetpack Compose를 사용할 때 상태를 사용하고 고려하는 방법을 살펴봅니다. 이를 위해 TODO 애플리케이션을 빌드합니다. 이 Codelab을 마치면 수정 가능한 대화형 TODO 목록을 표시하는 스테이트풀(Stateful) UI가 빌드됩니다.

b5c4dc05d1e54d5a.png

다음 섹션에서는 Compose 사용 시 상태를 표시하고 관리하는 방법을 이해하는 데 중요한 디자인 패턴인 단방향 데이터 흐름을 알아봅니다.

학습할 내용

  • 단방향 데이터 흐름이란?
  • UI에서 상태 및 이벤트를 고려하는 방법
  • Compose에서 아키텍처 구성요소의 ViewModelLiveData를 사용하여 상태를 관리하는 방법
  • Compose에서 상태를 사용하여 화면을 그리는 방법
  • 상태를 호출자로 이동하는 경우
  • Compose에서 내부 상태를 사용하는 방법
  • State<T>를 사용하여 상태를 Compose와 통합하는 방법

필요한 항목

빌드할 항목

  • Compose에서 단방향 데이터 흐름을 사용하는 대화형 TODO 앱

2. 설정

샘플 앱을 다운로드하려면 다음 중 하나를 실행하세요.

또는 다음 명령어를 사용하여 명령줄에서 GitHub 저장소를 클론합니다.

git clone https://github.com/googlecodelabs/android-compose-codelabs.git
cd android-compose-codelabs/StateCodelab

언제든지 툴바에서 실행 구성을 변경하여 Android 스튜디오에서 어느 한 모듈을 실행할 수 있습니다.

b059413b0cf9113a.png

Android 스튜디오로 프로젝트 열기

  1. 'Welcome to Android Studio' 창에서 c01826594f360d94.png Open an Existing Project를 선택합니다.
  2. [Download Location]/StateCodelab 폴더를 선택합니다(도움말: build.gradle이 포함된 StateCodelab 디렉터리를 선택해야 함).
  3. Android 스튜디오에서 프로젝트를 가져오면 startfinished 모듈을 실행할 수 있는지 테스트합니다.

시작 코드 살펴보기

시작 코드에는 패키지가 네 개 포함되어 있습니다.

  • examples: 단방향 데이터 흐름의 개념을 알아보기 위한 활동 예시입니다. 이 패키지는 수정할 필요가 없습니다.
  • ui: 새 Compose 프로젝트를 시작할 때 Android 스튜디오에서 자동 생성된 테마가 포함되어 있습니다. 이 패키지는 수정할 필요가 없습니다.
  • util: 프로젝트의 도우미 코드가 포함되어 있습니다. 이 패키지는 수정할 필요가 없습니다.
  • todo: 빌드 중인 Todo 화면의 코드가 포함된 패키지입니다. 이 패키지를 수정합니다.

이 Codelab에서는 todo 패키지의 파일에 중점을 둡니다. start 모듈에 있는 여러 파일에 익숙해져야 합니다.

todo 패키지에 제공된 파일

  • Data.kt: TodoItem을 나타내는 데 사용되는 데이터 구조입니다.
  • TodoComponents.kt: Todo 화면을 빌드하는 데 사용할 재사용 가능한 컴포저블입니다. 이 파일은 수정할 필요가 없습니다.

todo 패키지에서 수정할 파일

  • TodoActivity.kt: 이 Codelab을 완료하면 Compose를 사용하여 Todo 화면을 그릴 Android 활동입니다.
  • TodoViewModel.kt: Todo 화면을 빌드하기 위해 Compose와 통합할 ViewModel입니다. 이 Codelab을 완료하면 이를 Compose에 연결하고 확장하여 더 많은 기능을 추가합니다.
  • TodoScreen.kt: 이 Codelab에서 빌드할 Todo 화면의 Compose 구현입니다.

3. 단방향 데이터 흐름 이해

UI 업데이트 루프

TODO 앱을 시작하기 전에 Android 뷰 시스템을 사용한 단방향 데이터 흐름 개념을 살펴보겠습니다.

상태가 업데이트되는 원인은 무엇일까요? 소개에서 상태는 시간이 지남에 따라 변하는 값이라고 설명했습니다. 이는 Android 애플리케이션의 상태를 설명하는 일부 내용일 뿐입니다.

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

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

f415ca9336d83142.png

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

Compose에서 상태 관리는 상태와 이벤트가 서로 상호작용하는 방식을 이해하는 것이 핵심입니다.

구조화되지 않은 상태

Compose를 시작하기 전에 Android 뷰 시스템에서 이벤트와 상태를 살펴보겠습니다. 'Hello, World' 상태로 사용자가 이름을 입력할 수 있는 Hello World Activity를 빌드합니다.

879ed27ccab2eed3.gif

이를 작성할 수 있는 한 가지 방법은 이벤트 콜백이 TextView에서 직접 상태를 설정하도록 하는 것이며, ViewBinding을 사용하는 코드는 다음과 같을 수 있습니다.

HelloCodelabActivity**.kt**

class HelloCodelabActivity : AppCompatActivity() {

   private lateinit var binding: ActivityHelloCodelabBinding
   var name = ""

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */
       binding.textInput.doAfterTextChanged {text ->
           name = text.toString()
           updateHello()
       }
   }

   private fun updateHello() {
       binding.helloText.text = "Hello, $name"
   }
}

이와 같은 코드는 작동하며 이러한 작은 예시의 경우에는 괜찮습니다. 하지만 UI가 늘어나면서 관리하기 어려워지는 경향이 있습니다.

이와 같이 빌드된 활동에 이벤트와 상태를 더 많이 추가하면 여러 문제가 발생할 수 있습니다.

  1. 테스트: UI의 상태가 Views와 얽혀 있으므로 이 코드를 테스트하기 어려울 수 있습니다.
  2. 부분 상태 업데이트: 화면에 더 많은 이벤트가 있는 경우 이벤트에 대한 응답으로 상태의 일부를 업데이트하는 것을 잊기 쉽습니다. 이에 따라 사용자에게 일관되지 않거나 잘못된 UI가 표시될 수 있습니다.
  3. 부분 UI 업데이트: 각 상태가 변경된 후 수동으로 UI를 업데이트하므로 때로 이를 잊기가 매우 쉽습니다. 이로 인해 사용자는 임의로 업데이트되는 오래된 데이터를 UI에서 볼 수 있습니다.
  4. 코드 복잡성: 이 패턴으로 코딩할 때는 일부 로직을 추출하기 어렵습니다. 따라서 코드가 읽고 이해하기 어려워지는 경향이 있습니다.

단방향 데이터 흐름 사용

구조화되지 않은 상태와 관련된 이러한 문제를 해결하기 위해 ViewModelLiveData가 포함된 Android 아키텍처 구성요소를 도입했습니다.

ViewModel을 사용하면 UI에서 상태를 추출하고 UI에서 상태를 업데이트하는 데 호출할 수 있는 이벤트를 정의할 수 있습니다. ViewModel을 사용하여 작성된 동일한 활동을 살펴보겠습니다.

8a331b9c1b392bef.png

HelloCodelabActivity.kt

class HelloCodelabViewModel: 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 HelloCodeLabActivityWithViewModel : AppCompatActivity() {
   private val helloViewModel by viewModels<HelloCodelabViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       /* ... */

       binding.textInput.doAfterTextChanged {
           helloViewModel.onNameChanged(it.toString())
       }

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

이 예시에서는 상태를 Activity에서 ViewModel로 이동했습니다. ViewModel에서 상태는 LiveData로 표시됩니다. LiveData관찰 가능한 상태 홀더로, 누구나 상태의 변경사항을 관찰할 수 있는 방법을 제공합니다. 그러면 상태가 변경될 때마다 UI에서 observe 메서드를 사용하여 UI를 업데이트합니다.

ViewModel은 하나의 이벤트 onNameChanged도 노출합니다. 이 이벤트는 EditText의 텍스트가 변경될 때마다 여기서 발생하는 것과 같이 사용자 이벤트에 응답하여 UI에서 호출됩니다.

앞서 설명한 UI 업데이트 루프로 돌아가면 이 ViewModel이 이벤트 및 상태와 어떻게 결합되는지 확인할 수 있습니다.

  • 이벤트: 텍스트 입력이 변경되면 UI가 onNameChanged를 호출합니다.
  • 상태 업데이트: onNameChanged가 처리를 진행하고 _name의 상태를 설정합니다.
  • 상태 표시: name의 관찰자가 호출되고 이는 상태 변경사항을 UI에 알립니다.

코드를 이런 식으로 구조화하면 ViewModel로 '위로' 흐르는 이벤트를 생각할 수 있습니다. 그러면 이벤트에 대한 응답으로 ViewModel이 일부 처리를 실행하고 상태를 업데이트할 수 있습니다. 상태가 업데이트되면 Activity로 '아래'로 흐릅니다.

상태는 ViewModel에서 활동으로 아래로 흐르고 이벤트는 활동에서 ViewModel로 위로 흐릅니다.

이 패턴을 단방향 데이터 흐름이라고 합니다. 단방향 데이터 흐름은 상태는 아래로 흐르고 이벤트는 위로 흐르는 디자인입니다. 이러한 방식으로 코드를 구조화하면 다음과 같은 몇 가지 이점이 있습니다.

  • 테스트 가능성: 상태를 표시하는 UI에서 상태를 분리하여 ViewModel과 활동을 모두 더 쉽게 테스트할 수 있습니다.
  • 상태 캡슐화: 상태는 한곳(ViewModel)에서 업데이트할 수 있으므로 UI가 늘어날 때 부분 상태 업데이트 버그가 발생할 가능성이 작습니다.
  • UI 일관성: 관찰 가능한 상태 홀더를 사용함으로써 모든 상태 업데이트가 UI에 즉시 반영됩니다.

따라서 이 접근 방식으로 코드가 약간 더 추가되지만 단방향 데이터 흐름을 사용하여 복잡한 상태와 이벤트를 더 쉽고 안정적으로 처리할 수 있습니다.

다음 섹션에서는 Compose에서 단방향 데이터 흐름을 사용하는 방법을 알아봅니다.

4. Compose 및 ViewModel

이전 섹션에서는 ViewModelLiveData를 사용하여 Android 뷰 시스템에서 단방향 데이터 흐름을 살펴봤습니다. 이제 Compose로 이동하고 ViewModels를 사용하여 Compose에서 단방향 데이터 흐름을 사용하는 방법을 살펴보겠습니다.

이 섹션이 끝나면 다음 화면이 빌드됩니다.

7998ef0a441d4b3.png

TodoScreen 컴포저블 살펴보기

다운로드한 코드에는 이 Codelab 전체에 걸쳐 사용하고 수정할 컴포저블이 여러 개 포함되어 있습니다.

TodoScreen.kt를 열고 기존 TodoScreen 컴포저블을 살펴봅니다.

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   /* ... */
}

이 컴포저블로 표시되는 항목을 확인하려면 오른쪽 상단의 Split 아이콘 52dd4dd99bae0aaf.png을 클릭하여 Android 스튜디오의 Preview 창을 사용합니다.

4cedcddc3df7c5d6.png

이 컴포저블은 수정 가능한 TODO 목록을 표시하지만 자체 상태는 없습니다. 상태는 변경될 수 있는 값이지만 TodoScreen의 인수는 수정할 수 없습니다.

  • items: 화면에 표시할 변경할 수 없는 항목 목록입니다.
  • onAddItem: 사용자가 항목 추가를 요청할 때의 이벤트입니다.
  • onRemoveItem: 사용자가 항목 삭제를 요청할 때의 이벤트입니다.

실제로 이 컴포저블은 스테이트리스(Stateless)입니다. 전달된 항목 목록만 표시되며 목록을 직접 수정할 방법은 없습니다. 대신 변경을 요청할 수 있는 두 개의 이벤트 onRemoveItemonAddItem이 전달됩니다.

스테이트리스(Stateless)라면 수정 가능한 목록을 표시하는 방법은 무엇일까요? 상태 끌어올리기라는 기법을 사용하면 됩니다. 상태 끌어올리기는 구성요소를 스테이트리스(Stateless)로 만들기 위해 상태를 위로 이동하는 패턴입니다. 스테이트리스(Stateless) 구성요소는 테스트하기가 쉽고 대체로 버그가 적으며 재사용 가능성이 더 높습니다.

따라서 이러한 매개변수의 조합을 통해 호출자가 이 컴포저블에서 상태를 끌어올릴 수 있습니다. 작동 방식을 확인하기 위해 이 컴포저블의 UI 업데이트 루프를 살펴보겠습니다.

  • 이벤트: 사용자가 항목을 추가 또는 삭제하도록 요청하면 TodoScreenonAddItem 또는 onRemoveItem을 호출합니다.
  • 상태 업데이트: TodoScreen 호출자가 상태를 업데이트하여 이러한 이벤트에 응답할 수 있습니다.
  • 상태 표시: 상태가 업데이트되면 TodoScreen이 새 items와 함께 다시 호출되어 화면에 표시할 수 있습니다.

호출자는 이 상태를 보관할 위치와 방법을 파악해야 합니다. items를 적절한 곳(예: 메모리)에 저장하거나 Room 데이터베이스에서 읽을 수 있습니다. TodoScreen은 상태 관리 방식과 완전히 분리됩니다.

TodoActivityScreen 컴포저블 정의

TodoViewModel.kt를 열고 상태 변수 하나와 이벤트 두 개를 정의하는 기존 ViewModel을 찾습니다.

TodoViewModel.kt

class TodoViewModel : ViewModel() {

   // state: todoItems
   private var _todoItems = MutableLiveData(listOf<TodoItem>())
   val todoItems: LiveData<List<TodoItem>> = _todoItems

   // event: addItem
   fun addItem(item: TodoItem) {
        /* ... */
   }

   // event: removeItem
   fun removeItem(item: TodoItem) {
        /* ... */
   }
}

ViewModel을 사용하여 TodoScreen에서 상태를 끌어올립니다. 완료하면 다음과 같은 단방향 데이터 흐름 디자인이 만들어집니다.

f555d7b9be40144c.png

TodoScreenTodoActivity에 통합하려면 TodoActivity.kt를 열고 새 @Composable 함수 TodoActivityScreen(todoViewModel: TodoViewModel)을 정의한 후 onCreatesetContent에서 호출합니다.

이 섹션의 나머지 부분에서는 TodoActivityScreen을 한 번에 한 단계씩 빌드합니다. 먼저 다음과 같은 가짜 상태와 이벤트로 TodoScreen을 호출합니다.

TodoActivity.kt

import androidx.compose.runtime.Composable

class TodoActivity : AppCompatActivity() {

   private val todoViewModel by viewModels<TodoViewModel>()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContent {
           StateCodelabTheme {
               Surface {
                   TodoActivityScreen(todoViewModel)
               }
           }
       }
   }
}

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>() // in the next steps we'll complete this
   TodoScreen(
       items = items,
       onAddItem = { }, // in the next steps we'll complete this
       onRemoveItem = { } // in the next steps we'll complete this
   )
}

이 컴포저블은 ViewModel에 저장된 상태와 프로젝트에 이미 정의되어 있는 TodoScreen 컴포저블 사이를 연결합니다. ViewModel을 직접 사용하도록 TodoScreen변경할 수 있지만 그러면 TodoScreen의 재사용성이 조금 떨어집니다. List<TodoItem>과 같은 더 간단한 매개변수를 선호함으로써 TodoScreen은 상태를 끌어올린 특정 위치에 결합되지 않습니다.

지금 앱을 실행하면 버튼이 표시되지만 버튼을 눌러도 아무 변화가 없는 것을 확인할 수 있습니다. 아직 ViewModelTodoScreen에 연결하지 않았기 때문입니다.

a195c5b4d2a5ea0f.png

이벤트를 위로 이동

이제 필요한 모든 구성요소(ViewModel, 브리지 컴포저블 TodoActivityScreen, TodoScreen)가 있으므로 단방향 데이터 흐름을 사용하여 동적 목록을 표시하도록 모두 함께 연결해 보겠습니다.

TodoActivityScreen에서 ViewModeladdItemremoveItem을 전달합니다.

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items = listOf<TodoItem>()
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

TodoScreenonAddItem이나 onRemoveItem을 호출하면 ViewModel의 올바른 이벤트로 호출을 전달할 수 있습니다.

상태를 아래로 전달

단방향 데이터 흐름의 이벤트를 연결했으므로 이제 상태를 아래로 전달해야 합니다.

observeAsState를 사용하여 todoItems LiveData를 관찰하도록 TodoActivityScreen을 수정합니다.

TodoActivity.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   val items: List<TodoItem> by todoViewModel.todoItems.observeAsState(listOf())
   TodoScreen(
       items = items,
       onAddItem = { todoViewModel.addItem(it) },
       onRemoveItem = { todoViewModel.removeItem(it) }
   )
}

이 줄은 LiveData를 관찰하고 현재 값을 List<TodoItem>으로 직접 사용할 수 있게 합니다.

이 한 줄에 다양한 정보가 들어 있으므로 하나씩 확인해 보겠습니다.

  • val items: List<TodoItem>List<TodoItem> 유형의 items 변수를 선언합니다.
  • todoViewModel.todoItemsViewModelLiveData<List<TodoItem>입니다.
  • .observeAsStateLiveData<T>를 관찰하여 State<T> 객체로 변환하므로 Compose에서 값 변경에 반응할 수 있습니다.
  • listOf()LiveData가 초기화되기 전에 가능한 null 결과를 피하기 위한 초깃값입니다. 전달되지 않으면 items는 null을 허용하는 List<TodoItem>?이 됩니다.
  • by는 Kotlin의 속성 위임 문법이며 이를 통해 자동으로 State<List<TodoItem>>observeAsState에서 일반 List<TodoItem>으로 래핑 해제할 수 있습니다.

앱 다시 실행

앱을 다시 실행하면 동적으로 업데이트되는 목록이 표시됩니다. 하단의 버튼을 클릭하면 새 항목이 추가되고, 항목을 클릭하면 삭제됩니다.

7998ef0a441d4b3.png

이 섹션에서는 ViewModels를 사용하여 Compose에서 단방향 데이터 흐름 디자인을 빌드하는 방법을 살펴봤습니다. 상태 끌어올리기라는 기법을 사용하여 스테이트리스(Stateless) UI를 표시하는 스테이트리스(Stateless) 컴포저블을 사용하는 방법도 확인했습니다. 상태이벤트와 관련하여 동적 UI를 고려하는 방법을 계속해서 알아봤습니다.

다음 섹션에서는 구성 가능한 함수에 메모리를 추가하는 방법을 살펴봅니다.

5. Compose의 메모리

ViewModel과 함께 Compose를 사용하여 단방향 데이터 흐름을 빌드하는 방법을 알아봤으므로 이제 Compose가 상태와 내부적으로 상호작용하는 방법을 살펴보겠습니다.

이전 섹션에서는 Compose가 컴포저블을 다시 호출하여 화면을 업데이트하는 방법을 확인했습니다. 이 프로세스를 재구성이라고 합니다. TodoScreen을 다시 호출하여 동적 목록을 표시할 수 있었습니다.

이 섹션과 다음 섹션에서는 스테이트풀(Stateful) 컴포저블을 만드는 방법을 알아봅니다.

이 섹션에서는 구성 가능한 함수에 메모리를 추가하는 방법을 설명합니다. 이는 다음 섹션에서 Compose에 상태를 추가하는 데 필요한 기본 요소입니다.

분산형 디자인

디자이너의 예시

40a46273d161497a.png

이 섹션에서는 팀의 새로운 디자이너가 최신 디자인 트렌드인 분산형 디자인을 따르는 예시를 제공했습니다. 분산형 디자인의 핵심 원칙은 좋은 디자인에 무작위로 보이는 변경사항을 추가하여 '흥미롭게' 보이도록 하는 것입니다.

이 디자인에서 각 아이콘은 0.3~0.9 사이의 임의의 알파로 색조가 지정됩니다.

컴포저블에 random 추가

시작하려면 TodoScreen.kt를 열고 TodoRow 컴포저블을 찾습니다. 이 컴포저블은 todo 목록의 단일 행을 설명합니다.

randomTint() 값으로 새 val iconAlpha를 정의합니다. 이는 디자이너가 요청한 것처럼 0.3~0.9 사이의 부동 소수점 수입니다. 그런 다음 아이콘의 색조를 설정합니다.

TodoScreen.kt

import androidx.compose.material.LocalContentColor

@Composable
fun TodoRow(todo: TodoItem, onItemClicked: (TodoItem) -> Unit, modifier: Modifier = Modifier) {
   Row(
       modifier = modifier
           .clickable { onItemClicked(todo) }
           .padding(horizontal = 16.dp, vertical = 8.dp),
       horizontalArrangement = Arrangement.SpaceBetween
   ) {
       Text(todo.task)
       val iconAlpha = randomTint()
       Icon(
           imageVector = todo.icon.imageVector,
           tint = LocalContentColor.current.copy(alpha = iconAlpha),
           contentDescription = stringResource(id = todo.icon.contentDescription)
       )
   }
}

미리보기를 다시 확인하면 이제 아이콘이 임의의 색조 색상으로 표시됩니다.

cdb483885e713651.png

재구성 살펴보기

앱을 다시 실행하여 새로운 분산형 디자인을 사용해 보세요. 색조가 항상 변하는 것처럼 보이는 것을 즉시 확인할 수 있습니다. 디자이너는 임의의 값을 사용했지만 너무 과한 것 같다고 말합니다.

목록이 변경될 때 색조가 변경되는 아이콘이 포함된 앱

2e53e9411aeee11e.gif

어떻게 된 것일까요? 목록이 변경될 때마다 재구성 프로세스가 화면의 각 행에 관해 randomTint를 다시 호출하는 것으로 확인됐습니다.

재구성은 새 입력으로 컴포저블을 다시 호출하여 Compose 트리를 업데이트하는 프로세스입니다. 여기서는 TodoScreen을 새 목록으로 다시 호출하면 LazyColumn이 화면의 모든 하위 요소를 재구성합니다. 그러면 TodoRow가 다시 호출되어 새로운 임의의 색조를 생성합니다.

Compose는 트리를 생성하지만 Android 뷰 시스템에서 익숙한 UI 트리와는 약간 다릅니다. UI 위젯 트리 대신 Compose는 컴포저블 트리를 생성합니다. 다음과 같이 TodoScreen을 시각화할 수 있습니다.

TodoScreen 트리

6f5faa4342c63d88.png

Compose에서 처음 컴포지션이 실행되면 호출된 모든 컴포저블의 트리가 빌드됩니다. 그러면 재구성 중에 호출된 새 컴포저블로 트리가 업데이트됩니다.

TodoRow가 재구성될 때마다 아이콘이 업데이트되는 이유는 TodoRow에 부수 효과가 숨겨져 있기 때문입니다. 부수 효과는 구성 가능한 함수 실행 외부에서 확인할 수 있는 모든 변경사항입니다.

Random.nextFloat()를 호출하면 의사 랜덤 숫자 생성기에 사용된 임의의 내부 변수가 업데이트됩니다. 이는 랜덤 숫자를 요청할 때마다 Random에서 다른 값을 반환하는 방법입니다.

구성 가능한 함수에 메모리 도입

TodoRow가 재구성될 때마다 색조가 변경되지 않아야 합니다. 이를 위해 마지막 컴포지션에 사용한 색조를 기억할 장소가 필요합니다. Compose를 사용하면 컴포지션 트리에 값을 저장할 수 있으므로 컴포지션 트리에 iconAlpha를 저장하도록 TodoRow를 업데이트할 수 있습니다.

TodoRow를 수정하고 다음과 같이 randomTint 호출을 remember로 묶습니다.

TodoScreen.kt

val iconAlpha: Float = remember(todo.id) { randomTint() }
Icon(
   imageVector = todo.icon.imageVector,
   tint = LocalContentColor.current.copy(alpha = iconAlpha),
   contentDescription = stringResource(id = todo.icon.contentDescription)
)

TodoRow의 새로운 Compose 트리를 살펴보면 iconAlpha가 Compose 트리에 추가된 것을 확인할 수 있습니다.

remember를 사용하는 TodoRow 트리

Compose 트리에서 iconAlpha가 새로운 TodoRow의 하위 요소로 표시되는 다이어그램

이제 앱을 다시 실행하면 목록이 변경될 때마다 색조가 업데이트되지 않습니다. 대신 재구성이 발생하면 remember로 저장된 이전 값이 반환됩니다.

remember 호출을 자세히 살펴보면 todo.idkey 인수로 전달되는 것을 확인할 수 있습니다.

remember(todo.id) { randomTint() }

remember 호출은 두 부분으로 구성됩니다.

  1. 키 인수: 이 remember에서 사용하는 '키'로, 괄호로 묶여 전달되는 부분입니다. 여기서는 todo.id를 키로 전달합니다.
  2. 계산: 기억될 새 값을 계산하는 람다로, 후행 람다로 전달됩니다. 여기서는 randomTint()를 사용하여 임의의 값을 계산합니다.

처음 구성될 때는 remember가 항상 randomTint를 호출하고 다음 재구성의 결과를 기억합니다. 또한 전달된 todo.id도 추적합니다. 그러면 새 todo.idTodoRow에 전달되지 않는 한 재구성 중에 randomTint 호출을 건너뛰고 기억된 값을 반환합니다.

컴포저블의 재구성은 멱등원이어야 합니다. randomTint 호출을 remember로 묶음으로써 todo 항목이 변경되지 않는 한 재구성 시 random 호출을 건너뜁니다. 결과적으로 TodoRow는 부수 효과가 없고 동일한 입력으로 재구성되고 멱등원일 때마다 같은 결과를 생성합니다.

기억된 값을 제어 가능하게 하기

이제 앱을 실행하면 각 아이콘에 임의의 색조가 표시되는 것을 볼 수 있습니다. 디자이너는 분산형 디자인 원칙을 따라서 만족스러워하며 판매를 승인합니다.

하지만 그 전에 코드를 약간 변경한 후 확인해야 합니다. 현재는 TodoRow 호출자가 색조를 지정할 방법이 없습니다. 색조를 지정하는 것이 좋은 이유에는 여러 가지가 있습니다. 예를 들어 제품 부문 부사장이 이 화면을 보고 앱을 출시하기 직전에 분산된 부분을 삭제하기 위해 핫픽스를 요구하는 경우입니다.

호출자가 이 값을 제어할 수 있으려면 remember 호출을 새 iconAlpha 매개변수의 기본 인수로 이동하기만 하면 됩니다.

@Composable
fun TodoRow(
   todo: TodoItem,
   onItemClicked: (TodoItem) -> Unit,
   modifier: Modifier = Modifier,
   iconAlpha: Float = remember(todo.id) { randomTint() }
) {
   Row(
       modifier = modifier
           .clickable { onItemClicked(todo) }
           .padding(horizontal = 16.dp)
           .padding(vertical = 8.dp),
       horizontalArrangement = Arrangement.SpaceBetween
   ) {
       Text(todo.task)
       Icon(
            imageVector = todo.icon.imageVector,
            tint = LocalContentColor.current.copy(alpha = iconAlpha),
            contentDescription = stringResource(id = todo.icon.contentDescription)
        )
   }
}

이제 호출자는 기본적으로 동일한 동작을 얻습니다. 즉, TodoRowrandomTint를 계산합니다. 하지만 원하는 알파를 지정할 수 있습니다. 호출자가 alphaTint를 제어하도록 허용하면 이 컴포저블의 재사용성이 높아집니다. 다른 화면에서는 디자이너가 모든 아이콘을 0.7 알파로 표시할 수 있습니다.

remember 사용과 관련된 정말 미세한 버그도 있습니다. '임의의 todo 추가'를 반복 클릭한 후 스크롤하여 몇 개를 화면 밖으로 스크롤할 수 있도록 todo 행을 충분히 추가해 보세요. 스크롤하면 화면으로 다시 스크롤될 때마다 아이콘이 알파로 변경되는 것을 확인할 수 있습니다.

다음 섹션에서는 이러한 버그를 해결하는 데 필요한 도구를 제공하는 상태 및 상태 끌어올리기를 살펴봅니다.

6. Compose에서의 상태

이전 섹션에서는 구성 가능한 함수에 메모리를 포함하는 방법을 알아봤습니다. 이제 이 메모리를 사용하여 컴포저블에 상태를 추가하는 방법을 살펴보겠습니다.

Todo 입력(상태: 펼침) 721446d6a55fcaba.png

Todo 입력(상태: 접힘) 6f46071227df3625.png

이제 디자이너가 분산형 디자인에서 포스트 머티리얼로 이동했습니다. todo 입력의 새로운 디자인은 접을 수 있는 헤더와 동일한 공간을 차지하며 펼친 상태와 접힌 상태 두 가지 기본 상태가 있습니다. 펼친 버전은 텍스트가 비어 있지 않을 때마다 표시됩니다.

이를 빌드하기 위해 먼저 텍스트와 버튼을 빌드한 후 자동 숨김 아이콘을 추가하는 방법을 살펴보겠습니다.

UI에서 텍스트를 수정하는 것은 스테이트풀(Stateful)입니다. 사용자는 문자를 입력할 때마다 또는 선택 항목을 변경할 때에도 현재 표시된 텍스트를 업데이트합니다. Android 뷰 시스템에서는 이 상태가 EditText 내부에 있으며 onTextChanged 리스너를 통해 노출됩니다. 하지만 Compose는 단방향 데이터 흐름을 위해 설계되었으므로 이는 적합하지 않습니다.

Compose의 TextField는 스테이트리스(Stateless) 컴포저블입니다. 변화하는 todo 목록을 표시하는 TodoScreen과 마찬가지로 TextField는 개발자가 지시하는 내용을 표시하고 사용자가 입력하면 이벤트를 실행합니다.

스테이트풀(Stateful) TextField 컴포저블 만들기

Compose에서 상태를 알아보기 위해 수정 가능한 TextField를 표시하는 스테이트풀(Stateful) 구성요소를 만듭니다.

시작하려면 TodoScreen.kt를 열고 다음 함수를 추가합니다.

TodoScreen.kt

import androidx.compose.runtime.mutableStateOf

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

이 함수는 remember를 사용하여 메모리를 자체적으로 추가한 다음 메모리에 mutableStateOf를 저장하여 관찰 가능한 상태 홀더를 제공하는 내장 Compose 유형인 MutableState<String>을 만듭니다.

값과 setter 이벤트를 즉시 TodoInputText에 전달하므로 MutableState 객체를 getter와 setter로 해체합니다.

이제 모두 완료되었습니다. TodoInputTextField에 내부 상태를 만들었습니다.

실제 동작을 확인하려면 TodoInputTextFieldButton을 표시하는 다른 컴포저블 TodoItemInput을 정의합니다.

TodoScreen.kt

import androidx.compose.ui.Alignment

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   // onItemComplete is an event will fire when an item is completed by the user
   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을 완료하면 이벤트가 트리거됩니다. 람다를 전달하는 이 패턴은 Compose에서 맞춤 이벤트를 정의하는 기본 방법입니다.

또한 이미 프로젝트에 정의되어 있는 배경 TodoItemInputBackground에서 TodoItemInput을 호출하도록 TodoScreen 컴포저블을 업데이트합니다.

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit
) {
   Column {
       // add TodoItemInputBackground and TodoItem at the top of TodoScreen
       TodoItemInputBackground(elevate = true, modifier = Modifier.fillMaxWidth()) {
           TodoItemInput(onItemComplete = onAddItem)
       }
...

TodoItemInput 사용해 보기

방금 파일에 주요 UI 컴포저블을 정의했으므로 @Preview를 추가하는 것이 좋습니다. 이렇게 하면 이 컴포저블을 개별적으로 살펴볼 수 있을 뿐만 아니라 이 파일의 리더가 빠르게 미리 볼 수 있습니다.

TodoScreen.kt에서 하단에 새 미리보기 함수를 추가합니다.

TodoScreen.kt

@Preview
@Composable
fun PreviewTodoItemInput() = TodoItemInput(onItemComplete = { })

이제 이 컴포저블을 대화형 미리보기 또는 에뮬레이터에서 실행하여 컴포저블을 개별적으로 디버그할 수 있습니다.

이렇게 하면 사용자가 텍스트를 수정할 수 있는 수정 가능한 텍스트 필드가 올바르게 표시됩니다. 문자를 입력할 때마다 상태가 업데이트되어 사용자에게 표시되는 TextField를 업데이트하는 재구성이 트리거됩니다.

대화형 상태로 실행되는 PreviewTodoItemInput

버튼을 클릭하여 항목 추가

이제 'Add' 버튼으로 실제로 TodoItem을 추가하겠습니다. 이렇게 하려면 TodoInputTextField에서 text에 액세스해야 합니다.

TodoItemInput의 컴포지션 트리 일부를 보면 TodoInputTextField 내부에 텍스트 상태가 저장되는 것을 확인할 수 있습니다.

TodoItemInput 컴포지션 트리(내장 컴포저블 숨김)

트리: 하위 요소 TodoInputTextField와 TodoEditButton이 있는 TodoItemInput  상태 텍스트는 TodoInputTextField의 하위 요소입니다.

이 구조에서는 onClick을 연결할 수 없습니다. onClick이 현재 text 값에 액세스해야 하기 때문입니다. text 상태를 TodoItemInput에 노출하는 동시에 단방향 데이터 흐름을 사용하는 것이 좋습니다.

단방향 데이터 흐름은 Jetpack Compose를 사용할 때 상위 수준 아키텍처와 단일 컴포저블 디자인에 모두 적용됩니다. 여기서는 이벤트는 항상 위로 흐르고 상태는 항상 아래로 흐르도록 만듭니다.

즉, 상태는 TodoItemInput에서 아래로 흐르고 이벤트는 위로 흐릅니다.

TodoItemInput의 단방향 데이터 흐름 다이어그램

다이어그램: 상단에 있는 TodoItemInput에서 상태가 TodoInputTextField로 아래로 흐릅니다. 이벤트는 TodoInputTextField에서 TodoItemInput으로 위로 흐릅니다.

이렇게 하려면 상태를 하위 컴포저블 TodoInputTextField에서 상위 TodoItemInput으로 이동해야 합니다.

상태 끌어올리기가 적용된 TodoItemInput 컴포지션 트리(내장 컴포저블 숨김)

e2ccddf8af39d228.png

이 패턴을 상태 끌어올리기라고 합니다. 컴포저블에서 상태를 '끌어올려' 스테이트리스(Stateless)로 만듭니다. 상태 끌어올리기는 Compose에서 단방향 데이터 흐름 디자인을 빌드하는 기본 패턴입니다.

상태 끌어올리기를 시작하려면 컴포저블의 내부 상태 T(value: T, onValueChange: (T) -> Unit) 매개변수 쌍으로 리팩터링하면 됩니다.

(value, onValueChange) 매개변수를 추가하여 상태를 끌어올리도록 TodoInputTextField를 수정합니다.

TodoScreen.kt

// TodoInputTextField with hoisted state

@Composable
fun TodoInputTextField(text: String, onTextChange: (String) -> Unit, modifier: Modifier) {
   TodoInputText(text, onTextChange, modifier)
}

이 코드는 valueonValueChange 매개변수를 TodoInputTextField에 추가합니다. 값 매개변수는 text이고 onValueChange 매개변수는 onTextChange입니다.

그러면 이제 상태를 끌어올리므로 TodoInputTextField에서 기억된 상태를 삭제합니다.

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

  • 단일 소스 저장소: 상태를 복제하는 대신 옮겼기 때문에 텍스트의 소스 저장소가 하나만 있습니다. 버그 방지에 도움이 됩니다.
  • 캡슐화됨: TodoItemInput만 상태를 수정할 수 있고, 다른 구성요소는 TodoItemInput으로 이벤트를 전송할 수 있습니다. 이 방법으로 끌어올리면 여러 컴포저블이 상태를 사용하더라도 스테이트풀(Stateful)인 컴포저블은 하나뿐입니다.
  • 공유 가능함: 끌어올린 상태는 변경할 수 없는 값으로 여러 컴포저블과 공유할 수 있습니다. 여기서는 TodoInputTextFieldTodoEditButton에서 모두 상태를 사용합니다.
  • 가로채기 가능함: TodoItemInput이 상태를 변경하기 전에 이벤트를 무시하거나 수정할지 결정할 수 있습니다. 예를 들어 TodoItemInput은 사용자가 입력할 때 :emoji-codes:의 형식을 그림 이모티콘으로 지정할 수 있습니다.
  • 분리됨: TodoInputTextField의 상태는 어디에나 저장할 수 있습니다. 예를 들어 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의 동작을 구동할 수 있습니다. 콜백을 완료하고 디자인에 따라 텍스트가 비어 있지 않을 때만 버튼을 enable합니다.

TodoScreen.kt

// edit TodoItemInput
TodoEditButton(
   onClick = {
       onItemComplete(TodoItem(text)) // send onItemComplete event up
       setText("") // clear the internal text
   },
   text = "Add",
   modifier = Modifier.align(Alignment.CenterVertically),
   enabled = text.isNotBlank() // enable if text is not blank
)

두 개의 다른 컴포저블에서 동일한 상태 변수 text를 사용합니다. 상태를 끌어올리면 이처럼 상태를 공유할 수 있습니다. TodoItemInput만 스테이트풀(Stateful) 컴포저블로 만들면서 이를 실행했습니다.

다시 실행

앱을 다시 실행하면 이제 todo 항목을 추가할 수 있습니다. 지금까지 컴포저블에 상태를 추가하는 방법과 상태를 끌어올리는 방법을 알아봤습니다.

767719165c35039e.png

코드 정리

계속 진행하기 전에 TodoInputTextField를 인라인 처리합니다. 상태 끌어올리기를 살펴보려고 이 섹션에 추가했습니다. 이 Codelab과 함께 제공된 TodoInputText의 코드를 살펴보면 이 섹션에서 설명한 패턴을 따라 이미 상태를 끌어리는 것을 확인할 수 있습니다.

이 과정을 완료하면 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를 빌드합니다.

7. 상태에 기반한 동적 UI

이전 섹션에서는 컴포저블에 상태를 추가하는 방법과 상태를 사용하는 컴포저블을 스테이트리스(Stateless)로 만들기 위해 상태 끌어올리기를 사용하는 방법을 알아봤습니다.

이제 상태에 기반한 동적 UI를 빌드하는 방법을 살펴보겠습니다. 디자이너의 예시로 돌아가 텍스트가 비어 있지 않을 때마다 아이콘 행을 표시해야 합니다.

Todo 입력(상태: 펼침. 텍스트가 비어 있지 않음) 721446d6a55fcaba.png

Todo 입력(상태: 접힘. 텍스트가 비어 있음) 6f46071227df3625.png

상태에서 iconsVisible 가져오기

TodoScreen.kt를 열고 현재 선택된 icon과 텍스트가 비어 있지 않을 때마다 true인 새 val iconsVisible을 저장할 새 상태 변수를 만듭니다.

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
    // ...

현재 선택된 아이콘이 저장되는 두 번째 상태 icon을 추가했습니다.

iconsVisibleTodoItemInput에 새로운 상태를 추가하지 않습니다. TodoItemInput에서 이를 직접 변경할 수는 없습니다. 대신 전적으로 text의 값을 기반으로 합니다. 이 재구성에 있는 text 값이 무엇이든 iconsVisible은 적절하게 설정되므로 올바른 UI를 표시하는 데 사용할 수 있습니다.

TodoItemInput에 또 다른 상태를 추가하여 아이콘이 표시되는 시점을 제어할 수도 있지만, 사양을 자세히 살펴보면 공개 상태는 전적으로 입력된 텍스트에 기반합니다. 상태를 두 개 만들면 쉽게 동기화되지 않을 수 있습니다.

대신 단일 소스 저장소를 사용하는 것이 좋습니다. 이 컴포저블에서는 text만 상태여야 하고 iconsVisibletext에 기반할 수 있습니다.

iconsVisible 값에 따라 AnimatedIconRow를 표시하도록 TodoItemInput을 계속 수정합니다. iconsVisible이 true인 경우 AnimatedIconRow를 표시하고 false인 경우 Spacer를 16.dp로 표시합니다.

TodoScreen.kt

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   Column {
       Row( /* ... */ ) {
           /* ... */
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

이제 앱을 다시 실행하면 텍스트를 입력할 때 아이콘이 애니메이션으로 표시됩니다.

여기서는 iconsVisible 값에 따라 컴포지션 트리를 동적으로 변경합니다. 다음은 두 상태의 컴포지션 트리 다이어그램입니다.

이런 종류의 조건부 표시 로직은 Android 뷰 시스템에서 사라진 공개 상태와 동일합니다.

iconsVisible 변경 시 TodoItemInput 컴포지션 트리

ceb75cf0f13a1590.png

앱을 다시 실행하면 아이콘 행이 올바르게 표시되지만 'Add'를 클릭하면 추가된 todo 행에 아이콘이 표시되지 않습니다. 새 아이콘 상태를 전달하도록 이벤트를 업데이트하지 않았기 때문입니다. 다음 단계에서 업데이트해 보겠습니다.

아이콘을 사용하도록 이벤트 업데이트

onClick 리스너에서 새 icon 상태를 사용하도록 TodoItemInputTodoEditButton을 수정합니다.

TodoScreen.kt

TodoEditButton(
   onClick = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   },
   text = "Add",
   modifier = Modifier.align(Alignment.CenterVertically),
   enabled = text.isNotBlank()
)

onClick 리스너에서 직접 새 icon 상태를 사용해도 됩니다. 또한 사용자가 TodoItem 입력을 완료하면 기본값으로 재설정합니다.

이제 앱을 실행하면 애니메이션 버튼이 포함된 대화형 todo 입력이 표시됩니다. 잘하셨습니다.

3d8320f055510332.gif

imeAction으로 디자인 완료

디자이너에게 앱을 보여주면 디자이너는 키보드의 IME 작업을 통해 todo 항목을 제출해야 한다고 알려줍니다. 오른쪽 하단에 있는 파란색 버튼입니다.

ImeAction.Done이 포함된 Android 키보드

6ee2444445ec12be.png

TodoInputText를 사용하면 onImeAction 이벤트로 imeAction에 응답할 수 있습니다.

onImeAction의 동작이 TodoEditButton의 동작과 정확히 동일해야 합니다. 코드를 복제할 수 있지만 이벤트 중 하나만 업데이트하기가 쉬우므로 시간이 지나면서 유지관리가 어려울 수 있습니다.

이벤트를 변수로 추출해 보겠습니다. 따라서 TodoInputTextonImeActionTodoEditButtononClick에 모두 사용할 수 있습니다.

TodoItemInput을 다시 수정하여 제출 작업을 실행하는 사용자를 처리하는 새로운 람다 함수 submit을 선언합니다. 그런 다음 새로 정의된 람다 함수를 TodoInputTextTodoEditButton에 모두 전달합니다.

TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   val submit = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   }
   Column {
       Row(Modifier
           .padding(horizontal = 16.dp)
           .padding(top = 16.dp)
       ) {
           TodoInputText(
               text = text,
               onTextChange = setText,
               modifier = Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               onImeAction = submit // pass the submit callback to TodoInputText
           )
           TodoEditButton(
               onClick = submit, // pass the submit callback to TodoEditButton
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, setIcon, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

원하는 경우 이 함수에서 로직을 추가로 추출할 수 있습니다. 그러나 이 컴포저블이 상당히 괜찮아 보이므로 여기서 중단합니다.

이는 Compose의 큰 장점 중 하나입니다. Kotlin에서 UI를 선언하므로 코드를 분리하고 재사용할 수 있도록 하는 데 필요한 추상화를 빌드할 수 있습니다.

키보드 작업을 처리하기 위해 TextField는 매개변수 두 개를 제공합니다.

  • keyboardOptions: 완료된 IME 작업 표시를 사용 설정하는 데 사용됩니다.
  • keyboardActions: 트리거된 특정 IME 작업에 응답하여 트리거될 작업을 지정하는 데 사용됩니다. 여기서는 '완료'를 누르면 submit을 호출하고 키보드를 숨기려고 합니다.

소프트웨어 키보드를 제어하기 위해 LocalSoftwareKeyboardController.current를 사용합니다. 이는 실험용 API이므로 함수를 @OptIn(ExperimentalComposeUiApi::class)로 주석 처리해야 합니다.

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun TodoInputText(
    text: String,
    onTextChange: (String) -> Unit,
    modifier: Modifier = Modifier,
    onImeAction: () -> Unit = {}
) {
    val keyboardController = LocalSoftwareKeyboardController.current
    TextField(
        value = text,
        onValueChange = onTextChange,
        colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Transparent),
        maxLines = 1,
        keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(onDone = {
            onImeAction()
            keyboardController?.hide()
        }),
        modifier = modifier
    )
}

앱을 다시 실행하여 새 아이콘 사용해 보기

앱을 다시 실행하면 텍스트가 상태를 변경할 때 아이콘이 자동으로 표시되고 숨겨집니다. 아이콘 선택을 변경할 수도 있습니다. 'Add' 버튼을 누르면 입력한 값을 기반으로 새 TodoItem이 생성됩니다.

축하합니다. Compose에서의 상태와 상태 끌어올리기, 상태를 기반으로 동적 UI를 빌드하는 방법을 알아봤습니다.

다음 섹션에서는 상태와 상호작용하는 재사용 가능한 구성요소를 만드는 방법을 살펴봅니다.

8. 스테이트리스(Stateless) 컴포저블 추출

디자이너가 오늘 새로운 디자인 트렌드를 경험하고 있습니다. 분산형 UI와 포스트 머티리얼이 사라지고 이번 주 디자인은 '신모던 대화형'이라는 디자인 트렌드를 따릅니다. 디자이너에게 그 의미를 물어보고 들은 답변은 조금 혼란스러웠고 그림 이모티콘이 포함되어 있었습니다. 그럼 살펴보겠습니다.

수정 모드 예시

수정 모드는 입력 모드와 동일한 UI를 재사용하지만 목록에 편집기를 삽입합니다.

디자이너는 입력과 동일한 UI를 재사용하고 버튼은 저장 및 완료 그림 이모티콘으로 변경된다고 말합니다.

지난 섹션을 마칠 때 TodoItemInput을 스테이트풀(Stateful) 컴포저블로 두었습니다. todo 입력에만 사용되면 괜찮았지만 지금은 편집기이므로 상태 끌어올리기를 지원해야 합니다.

이 섹션에서는 스테이트풀(Stateful) 컴포저블에서 상태를 추출하여 스테이트리스(Stateless)로 만드는 방법을 알아봅니다. 이를 통해 todo 추가 및 수정에 모두 동일한 컴포저블을 재사용할 수 있습니다.

TodoItemInput을 스테이트리스(Stateless) 컴포저블로 변환

시작하려면 TodoItemInput에서 상태를 끌어올려야 합니다. 하지만 어디에 배치해야 할까요? TodoScreen에 직접 배치할 수 있지만, 내부 상태와 완료된 이벤트에서 이미 잘 작동하고 있습니다. 이 API는 변경하지 않아야 합니다.

대신 할 수 있는 작업은 컴포저블을 두 개로 분할하는 것입니다. 하나는 상태가 포함되어 있고 다른 하나는 스테이트리스(Stateless)입니다.

TodoScreen.kt를 열고 TodoItemInput을 컴포저블 두 개로 분할한 다음 스테이트풀(Stateful) 컴포저블을 TodoItemEntryInput으로 이름을 바꿉니다. 새 TodoItems를 입력하는 데만 유용하기 때문입니다.

TodoScreen.kt

@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, setText) = remember { mutableStateOf("") }
   val (icon, setIcon) = remember { mutableStateOf(TodoIcon.Default)}
   val iconsVisible = text.isNotBlank()
   val submit = {
       onItemComplete(TodoItem(text, icon))
       setIcon(TodoIcon.Default)
       setText("")
   }
   TodoItemInput(
       text = text,
       onTextChange = setText,
       icon = icon,
       onIconChange = setIcon,
       submit = submit,
       iconsVisible = iconsVisible
   )
}

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean
) {
   Column {
       Row(
           Modifier
               .padding(horizontal = 16.dp)
               .padding(top = 16.dp)
       ) {
           TodoInputText(
               text,
               onTextChange,
               Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               submit
           )
           TodoEditButton(
               onClick = submit,
               text = "Add",
               modifier = Modifier.align(Alignment.CenterVertically),
               enabled = text.isNotBlank()
           )
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

이 변환은 Compose를 사용할 때 이해해야 하는 중요한 부분입니다. 스테이트풀(Stateful) 컴포저블 TodoItemInput을 컴포저블 두 개로 분할했습니다. 하나는 상태가 있고(TodoItemEntryInput) 다른 하나는 스테이트리스(Stateless)(TodoItemInput)입니다.

스테이트리스(Stateless) 컴포저블에는 UI 관련 코드가 모두 있고 스테이트풀(Stateful) 컴포저블에는 UI 관련 코드가 없습니다. 이를 통해 상태를 다른 방식으로 지원하고자 하는 상황에서 UI 코드를 재사용 가능하게 만들 수 있습니다.

애플리케이션 다시 실행

애플리케이션을 다시 실행하여 todo 입력이 계속 작동하는지 확인합니다.

수고하셨습니다. API를 변경하지 않고 스테이트풀(Stateful) 컴포저블에서 스테이트리스(Stateless) 컴포저블을 추출했습니다.

다음 섹션에서는 이를 통해 UI와 상태를 결합하지 않고 여러 위치에서 UI 로직을 재사용할 수 있는 방법을 알아봅니다.

9. ViewModel에서 상태 사용

디자이너의 신모던 대화형 예시를 검토하면서 현재 수정 항목을 나타내는 몇 가지 상태를 추가해야 합니다.

수정 모드 예시

수정 모드는 입력 모드와 동일한 UI를 재사용하지만 목록에 편집기를 삽입합니다.

이제 이 편집기의 상태를 추가할 위치를 결정해야 합니다. 항목 표시나 수정을 처리하는 다른 스테이트풀(Stateful) 컴포저블 'TodoRowOrInlineEditor'를 빌드할 수 있지만 한 번에 편집기를 하나만 표시하려고 합니다. 디자인을 자세히 살펴보면 수정 모드에서도 상단 섹션이 변경됩니다. 따라서 상태를 공유할 수 있도록 상태 끌어올리기를 실행해야 합니다.

TodoActivity의 상태 트리

d32f2646a3f5ce65.png

TodoItemEntryInputTodoInlineEditor에서 모두 현재 편집기 상태를 알아야 화면 상단에서 입력을 숨길 수 있으므로 상태를 적어도 TodoScreen으로 끌어올려야 합니다. 화면은 계층 구조에서 최하위 수준 컴포저블로, 수정에 관해 알아야 하는 모든 컴포저블의 공통 상위 요소입니다.

그러나 편집기가 목록에서 파생되고 목록을 변형하므로 목록 옆에 실제로 있어야 합니다. 수정될 수 있는 수준으로 상태를 끌어올리고자 합니다. 목록은 TodoViewModel에 있으므로 정확하게 여기에 추가합니다.

mutableStateListOf를 사용하도록 TodoViewModel 변환

이 섹션에서는 TodoViewModel의 편집기 상태를 추가하고 다음 섹션에서는 이를 사용하여 인라인 편집기를 빌드합니다.

그러면서 ViewModel에서 mutableStateListOf를 사용하는 방법을 알아보고 Compose를 타겟팅할 때 LiveData<List>와 비교하여 상태 코드를 어떻게 간소화하는지 확인합니다.

mutableStateListOf를 사용하면 관찰 가능한 MutableList의 인스턴스를 만들 수 있습니다. 즉, MutableList와 동일한 방식으로 todoItems를 사용할 수 있어 LiveData<List> 사용의 오버헤드가 삭제됩니다.

TodoViewModel.kt를 열고 기존 todoItemsmutableStateListOf로 바꿉니다.

TodoViewModel.kt

import androidx.compose.runtime.mutableStateListOf

class TodoViewModel : ViewModel() {

   // remove the LiveData and replace it with a mutableStateListOf
   //private var _todoItems = MutableLiveData(listOf<TodoItem>())
   //val todoItems: LiveData<List<TodoItem>> = _todoItems

   // state: todoItems
   var todoItems = mutableStateListOf<TodoItem>()
    private set

   // event: addItem
   fun addItem(item: TodoItem) {
        todoItems.add(item)
   }

   // event: removeItem
   fun removeItem(item: TodoItem) {
       todoItems.remove(item)
   }
}

todoItems 선언은 짧으며 LiveData 버전과 동일한 동작을 캡처합니다.

// state: todoItems
var todoItems = mutableStateListOf<TodoItem>()
    private set

private set를 지정하면 이 상태 객체에 쓰기가 ViewModel 내부에만 표시되는 비공개 setter로 제한됩니다.

새 ViewModel을 사용하도록 TodoActivityScreen 업데이트

TodoActivity.kt를 열고 새 ViewModel을 사용하도록 TodoActivityScreen을 업데이트합니다.

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   TodoScreen(
       items = todoViewModel.todoItems,
       onAddItem = todoViewModel::addItem,
       onRemoveItem = todoViewModel::removeItem
   )
}

앱을 다시 실행하면 새 ViewModel과 호환됩니다. mutableStateListOf를 사용하도록 상태를 변경했습니다. 이제 편집기 상태를 만드는 방법을 살펴보겠습니다.

편집기 상태 정의

이제 편집기의 상태를 추가해 보겠습니다. todo 텍스트가 중복되지 않도록 목록을 직접 수정합니다. 이를 위해 편집 중인 현재 텍스트를 유지하는 대신 현재 편집기 항목의 목록 색인을 유지합니다.

TodoViewModel.kt를 열고 편집기 상태를 추가합니다.

현재 수정 위치가 저장된 새 private var currentEditPosition을 정의합니다. 현재 수정 중인 목록 색인이 저장됩니다.

그런 다음 getter를 사용하여 Compose에 currentEditItem을 노출합니다. 일반 Kotlin 함수이지만 currentEditPositionState<TodoItem>처럼 Compose에서 관찰할 수 있습니다.

TodoViewModel.kt

import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class TodoViewModel : ViewModel() {

   // private state
   private var currentEditPosition by mutableStateOf(-1)

    // state: todoItems
    var todoItems = mutableStateListOf<TodoItem>()
        private set

   // state
   val currentEditItem: TodoItem?
       get() = todoItems.getOrNull(currentEditPosition)

   // ..

컴포저블이 currentEditItem을 호출할 때마다 todoItemscurrentEditPosition의 변경사항을 모두 관찰합니다. 둘 중 하나가 변경되면 컴포저블은 getter를 다시 호출하여 새 값을 가져옵니다.

편집기 이벤트 정의

편집기 상태를 정의했으므로 이제 컴포저블이 수정을 제어하기 위해 호출할 수 있는 이벤트를 정의해야 합니다.

다음 세 이벤트를 만듭니다. onEditItemSelected(item: TodoItem), onEditDone(), onEditItemChange(item: TodoItem)

onEditItemSelectedonEditDone 이벤트는 currentEditPosition을 변경하기만 합니다. currentEditPosition을 변경하면 Compose는 currentEditItem을 읽는 모든 컴포저블을 재구성합니다.

TodoViewModel.kt

class TodoViewModel : ViewModel() {
   ...

   // event: onEditItemSelected
   fun onEditItemSelected(item: TodoItem) {
      currentEditPosition = todoItems.indexOf(item)
   }

   // event: onEditDone
   fun onEditDone() {
      currentEditPosition = -1
   }

   // event: onEditItemChange
   fun onEditItemChange(item: TodoItem) {
      val currentItem = requireNotNull(currentEditItem)
      require(currentItem.id == item.id) {
          "You can only change an item with the same id as currentEditItem"
      }

      todoItems[currentEditPosition] = item
   }
}

onEditItemChange 이벤트는 currentEditPosition에서 목록을 업데이트합니다. 그러면 currentEditItemtodoItems에서 반환된 값이 모두 동시에 변경됩니다. 그 전에 호출자가 잘못된 항목을 쓰려고 하지 않는지 확인하는 안전 확인이 있습니다.

항목 삭제 시 수정 종료

항목이 삭제될 때 현재 편집기를 닫도록 removeItem 이벤트를 업데이트합니다.

TodoViewModel.kt

// event: removeItem
fun removeItem(item: TodoItem) {
   todoItems.remove(item)
   onEditDone() // don't keep the editor open when removing items
}

앱 다시 실행

이제 모두 완료되었습니다. MutableState를 사용하도록 ViewModel을 업데이트하고 관찰 가능한 상태 코드를 어떻게 간소화하는지 확인했습니다.

다음 섹션에서는 이 ViewModel 테스트를 추가하고 수정 UI를 빌드합니다.

이 섹션에서는 수정한 내용이 많았습니다. 다음은 모든 변경사항이 적용된 후 TodoViewModel의 전체 목록입니다.

TodoViewModel.kt

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel

class TodoViewModel : ViewModel() {

    private var currentEditPosition by mutableStateOf(-1)

    var todoItems = mutableStateListOf<TodoItem>()
        private set

    val currentEditItem: TodoItem?
        get() = todoItems.getOrNull(currentEditPosition)

    fun addItem(item: TodoItem) {
        todoItems.add(item)
    }

    fun removeItem(item: TodoItem) {
        todoItems.remove(item)
        onEditDone() // don't keep the editor open when removing items
    }

    fun onEditItemSelected(item: TodoItem) {
        currentEditPosition = todoItems.indexOf(item)
    }

    fun onEditDone() {
        currentEditPosition = -1
    }

    fun onEditItemChange(item: TodoItem) {
        val currentItem = requireNotNull(currentEditItem)
        require(currentItem.id == item.id) {
            "You can only change an item with the same id as currentEditItem"
        }

        todoItems[currentEditPosition] = item
    }
}

10. ViewModel의 상태 테스트

ViewModel을 테스트하여 애플리케이션 로직이 올바른지 확인하는 것이 좋습니다. 이 섹션에서는 상태에 State<T>를 사용하여 뷰 모델을 테스트하는 방법을 보여주는 테스트를 작성합니다.

TodoViewModelTest에 테스트 추가

test/ 디렉터리에서 TodoViewModelTest.kt를 열고 항목 삭제 테스트를 추가합니다.

TodoViewModelTest.kt

import com.example.statecodelab.util.generateRandomTodoItem
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class TodoViewModelTest {

   @Test
   fun whenRemovingItem_updatesList() {
       // before
       val viewModel = TodoViewModel()
       val item1 = generateRandomTodoItem()
       val item2 = generateRandomTodoItem()
       viewModel.addItem(item1)
       viewModel.addItem(item2)

       // during
       viewModel.removeItem(item1)

       // after
       assertThat(viewModel.todoItems).isEqualTo(listOf(item2))
   }
}

이 테스트는 이벤트로 직접 수정되는 State<T>를 테스트하는 방법을 보여줍니다. 이전 섹션에서는 새 ViewModel을 만든 후 todoItems에 항목 두 개를 추가합니다.

테스트하는 메서드는 removeItem이며 목록에서 첫 번째 항목을 삭제합니다.

마지막으로 Truth 어설션을 사용하여 목록에 두 번째 항목만 포함되어 있는지 어설션합니다.

업데이트가 테스트로 인해 직접 발생한 경우(removeItem을 호출하여 여기서 실행하는 것처럼) 테스트에서 todoItems을 읽기 위해 추가 작업을 할 필요가 없습니다. List<TodoItem>일 뿐입니다.

ViewModel의 나머지 테스트는 동일한 기본 패턴을 따르므로 이 Codelab의 연습으로 건너뜁니다. ViewModel 테스트를 더 추가하여 작동하는지 확인하거나 완료된 모듈에서 TodoViewModelTest를 열어 더 많은 테스트를 확인할 수 있습니다.

다음 섹션에서는 새 수정 모드를 UI에 추가합니다.

11. 스테이트리스(Stateless) 컴포저블 재사용

마침내 신모던 대화형 디자인을 구현할 준비가 되었습니다. 참고로 빌드하려는 것은 다음과 같습니다.

수정 모드 예시

수정 모드는 입력 모드와 동일한 UI를 재사용하지만 목록에 편집기를 삽입합니다.

상태 및 이벤트를 TodoScreen에 전달

지금까지 TodoViewModel에서 이 화면에 필요한 상태와 이벤트를 모두 정의했습니다. 이제 화면을 표시하는 데 필요한 상태와 이벤트를 가져오도록 TodoScreen을 업데이트합니다.

TodoScreen.kt을 열고 TodoScreen의 서명을 변경하여 다음을 추가합니다.

  • 현재 수정 중인 항목: currentlyEditing: TodoItem?
  • 세 가지 새로운 이벤트는 다음과 같습니다.

onStartEdit: (TodoItem) -> Unit, onEditItemChange: (TodoItem) -> Unit, onEditDone: () -> Unit

TodoScreen.kt

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   currentlyEditing: TodoItem?,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit,
   onStartEdit: (TodoItem) -> Unit,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit
) {
   // ...
}

ViewModel에서 정의한 새 상태와 이벤트일 뿐입니다.

그런 다음 TodoActivity.kt에서 TodoActivityScreen에 새 값을 전달합니다.

TodoActivity.kt

@Composable
private fun TodoActivityScreen(todoViewModel: TodoViewModel) {
   TodoScreen(
       items = todoViewModel.todoItems,
       currentlyEditing = todoViewModel.currentEditItem,
       onAddItem = todoViewModel::addItem,
       onRemoveItem = todoViewModel::removeItem,
       onStartEdit = todoViewModel::onEditItemSelected,
       onEditItemChange = todoViewModel::onEditItemChange,
       onEditDone = todoViewModel::onEditDone
   )
}

그러면 새 TodoScreen에 필요한 상태와 이벤트가 전달됩니다.

인라인 편집기 컴포저블 정의

스테이트리스(Stateless) 컴포저블 TodoItemInput을 사용하여 인라인 편집기를 정의하는 새 컴포저블을 TodoScreen.kt에 만듭니다.

TodoScreen.kt

@Composable
fun TodoItemInlineEditor(
   item: TodoItem,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit,
   onRemoveItem: () -> Unit
) = TodoItemInput(
   text = item.task,
   onTextChange = { onEditItemChange(item.copy(task = it)) },
   icon = item.icon,
   onIconChange = { onEditItemChange(item.copy(icon = it)) },
   submit = onEditDone,
   iconsVisible = true
)

이 컴포저블은 스테이트리스(Stateless)입니다. 전달된 item만 표시하고 이벤트를 사용하여 상태 업데이트를 요청합니다. 이전에 스테이트리스(Stateless) 컴포저블 TodoItemInput을 추출했으므로 이 스테이트리스(Stateless) 컨텍스트에서 쉽게 사용할 수 있습니다.

이 예시에서는 스테이트리스(Stateless) 컴포저블의 재사용성을 보여줍니다. 헤더가 동일한 화면에서 스테이트풀(Stateful) TodoItemEntryInput을 사용하지만 인라인 편집기의 경우 상태를 ViewModel까지 끌어올릴 수 있습니다.

LazyColumn에서 인라인 편집기 사용

TodoScreenLazyColumn에서, 현재 항목이 수정 중이면 TodoItemInlineEditor를, 그렇지 않으면 TodoRow를 표시합니다.

또한 항목을 클릭할 때 수정을 시작합니다(이전처럼 삭제하지 않고).

TodoScreen.kt

// fun TodoScreen()
// ...
LazyColumn(
   modifier = Modifier.weight(1f),
   contentPadding = PaddingValues(top = 8.dp)
) {
 items(items) { todo ->
   if (currentlyEditing?.id == todo.id) {
       TodoItemInlineEditor(
           item = currentlyEditing,
           onEditItemChange = onEditItemChange,
           onEditDone = onEditDone,
           onRemoveItem = { onRemoveItem(todo) }
       )
   } else {
       TodoRow(
           todo,
           { onStartEdit(it) },
           Modifier.fillParentMaxWidth()
       )
   }
 }
}
// ...

LazyColumn 컴포저블은 Compose에서 RecyclerView와 동일합니다. 현재 화면을 표시하는 데 필요한 목록의 항목만 재구성하며 사용자가 스크롤할 때 화면을 벗어나는 컴포저블을 삭제하고 화면으로 스크롤되는 요소의 컴포저블을 새로 만듭니다.

새로운 대화형 편집기 사용해 보기

앱을 다시 실행합니다. todo 행을 클릭하면 대화형 편집기가 열립니다.

Codelab의 현 시점의 앱을 보여주는 이미지

동일한 스테이트리스(Stateless) UI 컴포저블을 사용하여 스테이트풀(Stateful) 헤더와 대화형 수정 환경을 모두 그립니다. 이 과정에서 중복 상태는 발생하지 않았습니다.

추가 버튼이 부적절해 보여 헤더를 변경해야 하지만 거의 완성되어 가는 것 같습니다. 다음 몇 단계에서 디자인을 마무리해 보겠습니다.

수정 시 헤더 바꾸기

이제 헤더 디자인을 완료하고 버튼을 디자이너가 신모던 대화형 디자인에 원하는 그림 이모티콘 버튼으로 바꾸는 방법을 알아봅니다.

TodoScreen 컴포저블로 돌아가서 헤더가 편집기 상태 변경사항에 응답하도록 합니다. currentlyEditingnull이면 TodoItemEntryInput을 표시하고 elevation = trueTodoItemInputBackground에 전달합니다. currentlyEditingnull이 아니면 elevation = falseTodoItemInputBackground에 전달하고 같은 배경에 '항목 수정'이라는 텍스트를 표시합니다.

TodoScreen.kt

import androidx.compose.material.MaterialTheme
import androidx.compose.ui.text.style.TextAlign

@Composable
fun TodoScreen(
   items: List<TodoItem>,
   currentlyEditing: TodoItem?,
   onAddItem: (TodoItem) -> Unit,
   onRemoveItem: (TodoItem) -> Unit,
   onStartEdit: (TodoItem) -> Unit,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit
) {
   Column {
       val enableTopSection = currentlyEditing == null
       TodoItemInputBackground(elevate = enableTopSection) {
           if (enableTopSection) {
               TodoItemEntryInput(onAddItem)
           } else {
               Text(
                   "Editing item",
                   style = MaterialTheme.typography.h6,
                   textAlign = TextAlign.Center,
                   modifier = Modifier
                       .align(Alignment.CenterVertically)
                       .padding(16.dp)
                       .fillMaxWidth()
               )
           }
       }
      // ..

재구성 시 Compose 트리를 변경하고 있습니다. 상단 섹션이 사용 설정되면 TodoItemEntryInput이 표시되고 그렇지 않으면 '항목 수정'을 표시하는 Text 컴포저블이 표시됩니다.

시작 코드에 있던 TodoItemInputBackground는 크기 조절뿐만 아니라 고도 변경사항에도 자동으로 애니메이션을 적용합니다. 따라서 수정 모드로 전환되면 이 코드가 상태 간에 자동으로 애니메이션됩니다.

앱 다시 실행

99c4d82c8df52606.gif

앱을 다시 실행하면 수정 상태와 비 수정 상태 사이의 전환을 애니메이션 처리합니다. 이 디자인은 거의 완성되었습니다.

다음 섹션에서는 그림 이모티콘 버튼의 코드를 구조화하는 방법을 알아봅니다.

12. 슬롯을 사용하여 화면의 섹션 전달

복잡한 UI를 표시하는 스테이트리스(Stateless) 컴포저블은 매개변수를 많이 사용할 수 있습니다. 매개변수가 너무 많지 않고 컴포저블을 직접 구성하는 경우에는 괜찮습니다. 그러나 컴포저블의 하위 요소를 구성하도록 매개변수를 전달해야 하는 경우도 있습니다.

툴바의 Add 버튼과 인라인 편집기의 그림 이모티콘 버튼이 있는 디자인

신모던 대화형 디자인에서 디자이너는 Add 버튼을 상단에 두되 인라인 편집기용 그림 이모티콘 버튼 두 개로 교체하기를 바랍니다. 이를 위해 TodoItemInput에 매개변수를 더 추가할 수 있지만, 실제로 TodoItemInput으로 처리해야 하는지는 분명하지 않습니다.

컴포저블이 사전 구성된 버튼 섹션을 사용할 방법이 필요합니다. 이를 통해 호출자는 TodoItemInput로 버튼을 구성하는 데 필요한 모든 상태를 공유하지 않고 필요한 버튼을 구성할 수 있습니다.

이렇게 하면 스테이트리스(Stateless) 컴포저블에 전달된 매개변수 수가 줄고 재사용성도 높아집니다.

사전 구성된 섹션을 전달하는 패턴은 슬롯입니다. 슬롯은 컴포저블의 매개변수로, 호출자가 화면의 섹션을 설명하도록 허용합니다. 내장 컴포저블 API 전체에서 슬롯 예시를 확인할 수 있습니다. 가장 흔히 사용되는 예시 중 하나는 Scaffold입니다.

Scaffold는 화면의 topBar, bottomBar, body와 같은 머티리얼 디자인의 전체 화면을 설명하는 컴포저블입니다.

화면의 각 섹션을 구성하기 위해 수백 개의 매개변수를 제공하는 대신 Scaffold는 원하는 컴포저블로 채울 수 있는 슬롯을 노출합니다. 이렇게 하면 Scaffold의 매개변수 수가 줄고 재사용성도 높아집니다. 맞춤 topBar를 빌드하려는 경우 Scaffold에서 표시합니다.

@Composable
fun Scaffold(
   // ..
   topBar: @Composable (() -> Unit)? = null,
   bottomBar: @Composable (() -> Unit)? = null,
   // ..
   bodyContent: @Composable (PaddingValues) -> Unit
) {

TodoItemInput에 슬롯 정의

TodoScreen.kt를 열고 buttonSlot이라는 스테이트리스(Stateless) TodoItemInput에 새 @Composable () -> Unit 매개변수를 정의합니다.

TodoScreen.kt

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean,
   buttonSlot: @Composable () -> Unit
) {
  // ...

이 슬롯은 호출자가 원하는 버튼으로 채울 수 있는 일반적인 슬롯입니다. 헤더와 인라인 편집기에 다양한 버튼을 지정하는 데 사용합니다.

buttonSlot의 콘텐츠 표시

TodoEditButton 호출을 슬롯의 콘텐츠로 바꿉니다.

TodoScreen.kt

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width

@Composable
fun TodoItemInput(
   text: String,
   onTextChange: (String) -> Unit,
   icon: TodoIcon,
   onIconChange: (TodoIcon) -> Unit,
   submit: () -> Unit,
   iconsVisible: Boolean,
   buttonSlot: @Composable() () -> Unit,
) {
   Column {
       Row(
           Modifier
               .padding(horizontal = 16.dp)
               .padding(top = 16.dp)
       ) {
           TodoInputText(
               text,
               onTextChange,
               Modifier
                   .weight(1f)
                   .padding(end = 8.dp),
               submit
           )

           // New code: Replace the call to TodoEditButton with the content of the slot

           Spacer(modifier = Modifier.width(8.dp))
           Box(Modifier.align(Alignment.CenterVertically)) { buttonSlot() }

           // End new code
       }
       if (iconsVisible) {
           AnimatedIconRow(icon, onIconChange, Modifier.padding(top = 8.dp))
       } else {
           Spacer(modifier = Modifier.height(16.dp))
       }
   }
}

buttonSlot()을 직접 호출할 수도 있지만 호출자가 수직으로 무엇을 전달하든 align을 가운데로 유지해야 합니다. 이를 위해 기본 컴포저블인 Box에 슬롯을 배치합니다.

슬롯을 사용하도록 스테이트풀(Stateful) TodoItemEntryInput 업데이트

이제 buttonSlot을 사용하도록 호출자를 업데이트해야 합니다. 먼저 TodoItemEntryInput을 업데이트해 보겠습니다.

TodoScreen.kt

@Composable
fun TodoItemEntryInput(onItemComplete: (TodoItem) -> Unit) {
   val (text, onTextChange) = remember { mutableStateOf("") }
   val (icon, onIconChange) = remember { mutableStateOf(TodoIcon.Default)}

   val submit = {
        if (text.isNotBlank()) {
            onItemComplete(TodoItem(text, icon))
            onTextChange("")
            onIconChange(TodoIcon.Default)
        }
   }
   TodoItemInput(
       text = text,
       onTextChange = onTextChange,
       icon = icon,
       onIconChange = onIconChange,
       submit = submit,
       iconsVisible = text.isNotBlank()
   ) {
       TodoEditButton(onClick = submit, text = "Add", enabled = text.isNotBlank())
   }
}

buttonSlotTodoItemInput의 마지막 매개변수이므로 후행 람다 문법을 사용할 수 있습니다. 그런 다음 람다에서 이전과 마찬가지로 TodoEditButton을 호출하면 됩니다.

슬롯을 사용하도록 TodoItemInlineEditor 업데이트

리팩터링을 완료하려면 슬롯을 사용하도록 TodoItemInlineEditor도 변경합니다.

TodoScreen.kt

import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.TextButton

@Composable
fun TodoItemInlineEditor(
   item: TodoItem,
   onEditItemChange: (TodoItem) -> Unit,
   onEditDone: () -> Unit,
   onRemoveItem: () -> Unit
) = TodoItemInput(
   text = item.task,
   onTextChange = { onEditItemChange(item.copy(task = it)) },
   icon = item.icon,
   onIconChange = { onEditItemChange(item.copy(icon = it)) },
   submit = onEditDone,
   iconsVisible = true,
   buttonSlot = {
       Row {
           val shrinkButtons = Modifier.widthIn(20.dp)
           TextButton(onClick = onEditDone, modifier = shrinkButtons) {
               Text(
                   text = "\uD83D\uDCBE", // floppy disk
                   textAlign = TextAlign.End,
                   modifier = Modifier.width(30.dp)
               )
           }
           TextButton(onClick = onRemoveItem, modifier = shrinkButtons) {
               Text(
                   text = "❌",
                   textAlign = TextAlign.End,
                   modifier = Modifier.width(30.dp)
               )
           }
       }
   }
)

여기서는 buttonSlot을 이름이 지정된 매개변수로 전달합니다. 그런 다음 buttonSlot에서 인라인 편집기 디자인용 버튼이 두 개 포함된 Row를 만듭니다.

앱 다시 실행

앱을 다시 실행하고 인라인 편집기를 사용해 봅니다.

ae3f79834a615ed0.gif

이 섹션에서는 호출자가 화면의 섹션을 제어하도록 허용하는 슬롯을 사용하여 스테이트리스(Stateless) 컴포저블을 맞춤설정했습니다. 슬롯을 사용함으로써 향후 추가될 수 있는 다양한 모든 디자인과 TodoItemInput을 결합하는 것을 방지했습니다.

스테이트리스(Stateless) 컴포저블에 매개변수를 추가하여 하위 요소를 맞춤설정하는 경우 슬롯이 더 나은 디자인인지 고려하세요. 슬롯을 사용하면 매개변수의 수를 잘 관리하면서 컴포저블의 재사용성도 높일 수 있습니다.

13. 축하합니다

축하합니다. 이 Codelab을 성공적으로 완료하고 Jetpack Compose 앱에서 단방향 데이터 흐름을 사용하여 상태를 구조화하는 방법을 배웠습니다.

상태와 이벤트를 고려하여 Compose에서 스테이트리스(Stateless) 컴포저블을 추출하는 방법을 알아봤고 동일한 화면의 여러 상황에서 복잡한 컴포저블을 재사용하는 방법을 살펴봤습니다. LiveData 및 MutableState를 모두 사용하여 ViewModel을 Compose와 통합하는 방법도 배웠습니다.

다음 단계

Compose 개발자 과정의 다른 Codelab을 확인하세요.

샘플 앱

  • JetNews는 스테이트리스(Stateless) 컴포저블을 사용하여 빌드된 화면에서 스테이트풀(Stateful) 컴포저블을 사용하여 상태를 관리하는 데 단방향 데이터 흐름을 사용하는 방법을 보여줍니다.

참조 문서