우리 디자이너의 네오 모던 인터랙티브 모의를 리뷰중, 현재 편집하는 아이템을 나타내는 몇가지 상태 추가가 필요해졌다.

편집 모드에서의 목업 디자인

이제 이 에디터의 상태를 추가할 위치를 결정해야 한다. 아이템 나타내거나 편집을 처리하는 또 다른 stateful 컴포저블인 “TodoRowOrInlineEditor”를 만들 수 있지만, 한 번에 하나의 에디터만 표시하려고 한다. 디자인을 자세히 보면 편집 모드에서도 상단 섹션이 변경되고 있다. 따라서 상태를 공유할 수 있도록 state hoisting을 수행해야 한다.

TodoActivity state 트리

TodoItemEntryInputTodoInlineEditor는 모두 화면 상단에서 입력을 숨길 수 있도록 현재 에디터 상태에 대해 알아야 하므로 상태를 최소한 TodoScreen으로 호이스트해야한다. 화면은 편집에 대해 알아야 하는 모든 컴포저블의 공통 상위 요소이며, 계층 구조에서 가장 낮은 수준의 컴포저블이다.

그러나 에디터는 목록에서 파생되고, 목록을 변경할 것이기 때문에 실제로는 목록 옆에 있어야 한다. state를 수정할 수 있는 수준으로 끌어 올리고 싶다. 목록은 TodoViewModel에 있기 때문에 그곳에 추가할 것이다.

상태를 끌어올릴 때, 그것이 어디로 가야하는지 알 수 있도록 돕는 3가지 규칙이 있다.

1. state는 state를 읽거나 사용하는 모든 컴포저블 중 최소한 가장 낮은 공통 부모로 hoisting 한다.

2. state는 변경 또는 수정 될 수 있는 최소한 가장 높은 수준으로 hoisting 한다.

3. 동일한 event에 대한 응답으로 두 state가 변경되면 함께 hoisting되어야 한다.

이러한 규칙이 요구하는 것보다 더 높은 수준으로 state를 hoisting할 수 있지만 state를 낮은 수준으로 hoisting하면 단방향 데이터 흐름을 따르기가 어렵거나 불가능하게 된다.

TodoViewModel을 변환하여 mutableStateListOf를 사용하기

이 섹션에서 TodoViewModel내 에디터에 대한 state를 추가해보도록 하자. 그리고 다음 섹션에서 이를 이용하여 inline 에디터를 만들게 된다.

그와 동시에, 우리는 ViewModel에 있는 mutableStateListOf에 대해서 알아보고, 컴포즈를 목표로 했을 때 LiveData<List>와 비교해서 state 코드가 얼마나 단순해지는지 살펴본다.

mutableStateListOf은 관찰가능한 MutableList 인스턴스를 생성하도록 한다. 이것은 우리가 MutableList로 작업하는 것과 같은 방식으로 todoItems로 작업할 수 있다는 것을 의미하며, LiveData<List> 작업의 오버헤드를 제거한다.

TodoViewModel.kt를 열고 기존 todoItemsmutableStateListOf로 대체하자.

// TodoViewModel.kt
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 내부에서만 볼 수 있는 private setter로 이 state 객체에 대한 쓰기를 제한하고 있다.

mutableStateListOf 및 MutableState로 수행된 작업은 Compose를 위해 의도된 것이다. 이 ViewModel이 View 시스템에서도 사용된 경우 LiveData를 계속 사용하는 것이 좋다.

새로운 ViewModel을 사용하기 위해 TodoActivityScreen 업데이트 하기

TodoActivity.kt를 열고 TodoActivityScreen을 수정하여 새로운 ViewModel을 사용할 수 있도록 하자.

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

앱을 다시 실행하면 새 ViewModel과 함께 작동하는 것을 볼 수 있다. mutableStateListOf를 사용하도록 상태를 변경했다. 이제 에디터 상태를 만드는 방법을 살펴보자.

에디터 상태 정의하기

이제 에디터의 상태를 추가할 차례다. todo text가 중복되는 것을 방지하기 위해 목록을 직접 편집할 것이다. 그렇게 하려면 현재 편집 중인 text를 유지하는 대신 현재 에디터 항목에 대한 목록 인덱스를 유지한다.

TodoViewModel.kt를 열고 에디터 상태를 추가하자.

현재 편집하고 있는 포지션 정보를 가지는 새로운 private var currentEditPosition를 정의한다. 현재 수정하고 있는 목록 인덱스를 갖게 될 것이다.

그런 뒤, currentEditItem을 노출시켜 컴포즈가 getter를 사용하도록 한다. 비록 일반적인 코틀린 함수지만 currentEditPositionState<TodoItem>과 같은 컴포즈에서 관찰할 수 있다.

// TodoViewModel.kt
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을 호출할 때마다, todoItems 및 currentEditPosition 둘의 변경사항을 관찰하게 된다. 둘중 하나가 변경되면 컴포저블은 getter를 다시 호출하여 새로운 값을 가져온다.

State<T> 변환은 일반적인 코틀린 코드다.

컴포즈는 컴포저블에 의해 호출된 일반 Kotlin 함수에서 읽기가 발생하더라도, 컴포저블에서 읽은 모든 State<T>를 관찰한다. 여기에서는 currentEditItem을 생성하기 위해 currentEditPosition 및 todoItems에서 읽어오고 있다. 컴포즈는 변경사항이 있을 때마다 currentEditItem을 읽는 컴포저블을 재구성한다.

State<T> 변환이 작동하려면, 반드시 State<T> 객체에서 state를 읽어야 한다.

currentEditPosition을 일반 Int(private var currentEditPosition = -1)로 정의한 경우 compose는 변경 사항을 관찰할 수 없다.

에디터 이벤트 정의하기

에디터 상태를 정의했으므로, 이제 컴포저블이 편집을 제어하기 위해 호출할 수 있는 event를 정의해야 한다.

3가지 event를 만들어보도록 하자.

  • onEditItemSelected(item:TodoItem)
  • onEditDone()
  • onEditItemChange(item:TodoItem)

onEditITemSelected 및 onEditDone은 단지 currentEditPosition을 변경한다. 변경된 currentEditPosition에 의해, 컴포즈는 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을 인덱스로 하는 목록에서 업데이트 한다. 이는 currentEditItem과 todoItems에 의해 동시에 반환된 값을 모두 변경한다. 그렇게 되기 전에, 호출자가 잘못된 아이템을 작성하려고 하지 않는지 확인하기 위한 몇가지 안전 검사가 있다.

아이템 지울 때 편집 끝내기

removeItem 이벤트를 업데이트 하여 아이템이 제거될 때 현재 에디터를 종료하자.

// TodoViewModel.kt
// event: removeItem
fun removeItem(item: TodoItem) {
   todoItems.remove(item)
   onEditDone() // 아이템을 제거할 때 에디터가 열린상태로 유지되지 않도록 한다.
}

앱 다시 시작하기

MutableState를 사용하도록 ViewModel을 업데이트하고 관찰 가능한 state 코드로 단순화할 수 있는 방법을 살펴보았다.

다음 섹션에서 이 ViewModel에 대한 테스트 코드를 추가한 다음, 편집 UI를 만들어보도록 한다.

이 섹션에서 많은 수정이 있었기 때문에, TodoViewModel에 대한 전체코드를 참조하자.

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
    }
}

State<T> 는 컴포즈에 의해 사용되도록 의도되었다.

컴포즈 바깥에서 사용하는 애플리케이션 state는 State<T>를 사용하지 않도록 한다.

카테고리: Compose

0개의 댓글

답글 남기기

Avatar placeholder

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