Week 2-2 Using state in Jetpack Compose (2)

jihyo·2021년 12월 9일

DevFest 2021

목록 보기
8/8

Compose에서의 메모리

ViewModel과 함께 Compose를 사용하여 단방향 데이터 흐름을 구축해봤으니 Compose가 내부적으로 상태와 상호작용하는 법에 대해 알아볼 것이다.

Stateful 컴포저블은 시간이 지남에 따라 변경될 수 있는 상태 조각을 가지는 컴포저블이다.

Disheveled Design 흐트러진 디자인

디자이너의 모의 디자인

Mock from designer
디자이너한테서 이런 "흐트러진" 디자인을 받았다고 생각해보자. 이런 디자인의 핵심은 좋은 디자인에 무작위로 보이는 변경 사항을 추가하여 "흥미롭게" 만드는 것이다.

컴포저블에 Random 도입

먼저 TodoScreen.kt를 열고 Todo 리스트의 단일 행을 표현하는 TodoRow 컴포저블을 확인한다.

randomTint() 값으로 새 val iconAlpha를 정의한다. 0.3~0.9 사이의 float 값으로 아이콘의 임의의 색을 결정한다.

TodoScreen.kt

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

LocalContentColor는 아이콘과 타이포그래피같은 콘텐츠에 대한 색상을 제공한다. 배경을 그리는 Surface와 같은 컴포저블에 의해 변경된다.

재구성 탐색

이제 앱을 실행하면 아이콘의 색이 변하는 것을 알 수 있다. 새로운 요구사항으로 변경 가능한 색의 범위가 너무 많다는 요청이 있다고 가정하자.

App with icons changing tint when list changes

앱을 실행 시켰을 때 항상 아이콘의 색상이 변하는 것을 알 수 있다. 이건 리스트가 변경될 때마다 재구성 프로세스가 화면의 각 행에 대해 randomTint를 다시 호출하기 때문이다.

재구성은 Compose 트리를 업데이트하기 위해 새 입력으로 다시 컴포저블을 호출하는 프로세스이다. 이 경우 새 리스트로 TodoScreen이 다시 호출되면 LazyColumn은 화면의 모든 자식을 재구성한다. 그러면 TodoRow가 다시 호출되어 새로운 임의 색상을 생성한다.

재구성은 데이터가 변경될 때 트리를 업데이트하기 위해 동일한 컴포저블을 다시 실행하는 프로세스이다.

Compose는 트리를 생성하지만 Android View 시스템에서의 UI 트리와는 다르다. UI 위젯 트리 대신 Compose는 컴포저블 트리를 생성한다. 아래는 예시로 시각화한 그림이다.
TodoScreen Tree

Compose가 처음으로 컴포지션을 실행할 때 호출된 모든 컴포저블의 트리를 빌드한다. 그 다음 재구성하는 동안 호출되는 새 컴포저블로 트리를 업데이트한다.

TodoRow가 재구성될 때마다 아이콘이 업데이트 되는 이유는 TodoRow부작용이 있기 때문이다. 컴포저블 함수 실행 외부에서 볼 수 있는 모든 변경사항이 부작용이다.

Random.nextFloat()에 대한 호출은 pseudo-난수 생성기에 사용된 내부 난수 변수를 업데이트한다. 이것이 Random이 난수를 요청할 때마다 다른 값을 반환하는 방법이다.

컴포저블을 재구성할 때 부작용이 없어야 한다. 예를 들어, ViewModel에서 상태 업데이트, Random.nextInt() 호출과 DB에 쓰는 것은 모두 부작용이다.

컴포저블 함수에 메모리 도입

TodoRow가 재구성할 때마다 색상이 변경되지 않게 하려면 마지막 컴포지션에서 사용한 색상을 기억할 공간이 필요하다. Compose를 사용하면 컴포지션 트리에 값을 저장할 수 있기 때문에 TodoRow를 업데이트하여 컴포지션 트리에 iconAlpha를 저장할 수 있다.

remember는 컴포저블 함수 메모리를 제공한다. remember에 의해 계산된 값은 컴포지션 트리에 저장되며 remember 할 키가 변경된 경우 다시 계산된다.
객체에서 private val 속성이 수행하는 것과 같은 방식으로 단일 객체에 대한 저장소를 함수에 제공하는 것으로 remember 할 수 있다.

TodoRow를 수정하고 randomTint에 대한 호출을 할 것이다.

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의 새 컴포즈 트리를 보면 iconAlpha가 컴포즈 트리에 추가된 걸 알 수 있다.

remember를 사용한 TodoRow 트리

TodoRow Tree
이제는 앱을 실행하면 리스트가 변경될 때마다 색이 바뀌지 않는(업데이트 되지 않는) 것을 알 수 있다. 대신, 재구성이 발생하면 메모리에 remember로 저장된 이전 값이 반환된다.

remember 호출을 자세히 살펴보면 todo.idkey 인수로 전달하고 있는 걸 알 수 있다.

remember(todo.id) { randomTint() }

remember 호출은 두 부분으로 구성되는데

  • key arguments : 이 메모리가 사용하는 "키"는 괄호 안에 전달되는 부분이다. 여기서 todo.id를 키로 전달한다.
  • calculation : remember될 새 값을 계산하는 람다로 후행 람다에 전달된다. 여기서 randomTint()를 사용해 임의의 값을 계산한다.

처음 구성할 때, remember는 항상 randomTint()를 호출하고 다음 재구성 결과를 기억한다. 전달된 todo.id도 추적한다. 그 후 재구성하는 동안 새로운 todo.idTodoRow에 전달되지 않는 한 randomTint() 호출을 건너뛰고 기억된 값을 반환한다.

컴포지션에 기억된 값은 호출 컴포저블이 트리에서 제거되는 즉시 잊혀진다.

호출하는 컴포저블이 트리에서 이동하는 경우에도 다시 초기화된다. LazyColumn에서 맨 위에 있는 item을 제거하여 발생시킬 수 있다.

idempotent 컴포저블은 항상 동일한 입력에 대해 동일한 결과를 생성하여 재구성에 대한 부작용이 없다. 그렇기 때문에 컴포저블은 재구성을 지원하기 위해 idempotent해야 한다.

컴포저블의 재구성은 idempotent해야 한다. randomTint()에 대한 호출을 remember로 감싸면 Todo item이 변경되지 않는 한 재구성 시 random에 대한 호출을 건너뛴다. 결과적으로 TodoRow는 부작용이 없으며 동일한 입력으로 재구성되고 idempotent될 때마다 항상 동일한 결과를 생성한다.

remember된 값을 제어 가능하게 만들기

TodoRow의 호출자가 색상을 지정할 방법이 없다. 호출자가 제어하려면 새 iconAlpha 매개변수의 기본 인자로 remember 호출을 이동시키면 된다.

@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를 제어할 수 있게 함으로써 TodoRow 컴포저블을 재사용할 수 있다.

컴포저블에 메모리를 추가할 때 "일부 호출자가 제어하기를 원할까요?"라고 했을 때

  • 대답이 "" 라면, 매개변수를 만든다.
  • 대답이 "아니오"라면, 로컬변수로 유지한다.

우리가 사용한 remember 사용에 버그가 하나 있는데, "Add random item" 버튼을 반복적으로 클릭하여 item을 추가했을 때, 스크롤 할 때마다 아이콘의 알파가 변경되는 것을 확인할 수 있다.

Compose에서의 상태

컴포저블 함수에 메모리를 추가했으니 메모리를 사용해 컴포저블에 상태를 추가하는 법을 알아볼 것이다.

Todo input(state: expanded)
Todo input expanded
Todo input(state: collapsed)
Todo input collapsed

todo 입력을 위한 새 디자인은 위 그림과 같이 2가지 상태가 있다. 확장된 버전은 텍스트가 비어있지 않을 때마다 표시된다.

위 화면을 구현하기 위해 먼저 텍스트와 버튼을 빌드하고 자동 숨기기 아이콘을 추가하는 방법으로 진행하겠다.

UI에서 텍스트 편집은 stateful하다. 사용자는 문자를 입력할 때나 선택을 바꿀 때 현재 표시된 텍스트를 업데이트한다. Android View 시스템에서 이 stateEditText 내부에 있으며 onTextChanged를 통해 노출되지만 Compose는 단방향 데이터 흐름을 위해 설계되었기 때문에 적합하지 않다.

TextField는 머티리얼의 EditText에 해당한다.

TextField : stateless 컴포저블이다. 변경되는 todo 리스트를 표시하는 TodoScreen과 마찬가지로 TextField는 사용자가 입력한 내용을 표시하고 이벤트를 발생시킨다.

내장 컴포저블은 단방향 데이터 흐름을 위해 설계되었다.
대부분의 기본 컴포저블은 각 API에 대해 하나 이상의 stateless 버전을 제공한다. View 시스템에 비해 내장 컴포저블은 editable 텍스트와 같은 stateful UI에 대해 내부 상태가 없는 옵션을 제공한다. 이렇게 되면 앱과 구성 요소 간에 상태가 중복되는 것을 방지할 수 있다.
예를 들어, Compose에서 Checkbox의 상태가 중복된 상태가 없는 서버 기반 API로 hoist될 수 있다.

stateful TextField 컴포저블 생성하기

Compose에서 상태 탐색을 시작하기 위해 editable TextField를 표시하는 상태 저장 구성 요소를 만들 것이다.

stateful 컴포저블은 시간이 지남에 따라 변경될 수 있는 상태 조각을 소유하는 컴포저블이다.

TodoScreen.kt를 열어 컴포저블 하나를 추가한다.

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

이 함수는 메모리를 추가하기 위해 메모리 사용 후 mutableStateOf를 저장하여 observable을 제공하는 Compose의 내장 유형인 MutableState<String>을 생성한다.

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

mutableStateOfCompose에 내장된 observable state holderMutableState<T>를 만든다.

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

값이 변경되면 이 상태를 읽는 컴포저블 함수가 자동으로 재구성된다.

MutableState 객체를 한 컴포저블에 선언하는 3가지 방법이 있다.

// 첫 번째 방법
val state = remember { mutableStateOf(default) }

// 두 번째 방법
var value by remember { mutableStateOf(default) }

// 세 번째 방법
val (value, setValue) = remember { mutableStateOf(default) }

컴포지션에서 State<T>를 만들 때 remember 하는게 중요하다. 그렇지 않으면 모든 컴포지션이 초기화된다.

MutableState<T>MutableLiveData<T>와 유사하지만 컴포즈 런타임과 통합되어있다. observable이기 때문에 업데이트될 때마다 컴포즈에 알리므로 컴포즈는 이를 읽는 모든 구성요소를 재구성할 수 있다.

TodoInputTextField에 내부 상태를 만들었다. 작동시키기 위해서는 TodoInputTextFieldButton을 표시하는 TodoItemInput 컴포저블을 만들어야 한다.
TodoScreen.kt

@Composable
fun TodoItemInput(onItemComplete: (TodoItem) -> Unit) {
	// onItemComplete는 사용자가 item을 완료했을 때 발생하는 이벤트
    Column {
    	Row(Modifier
        	.padding(horizontal = 16.dp)
            .padding(top = 16.dp)
        ) {
        	TodoInputTextField(Modifier
            	.weight(1f)
                .padding(end = 8.dp)
            )
            TodoEditButton(
            	onClick = {  },
                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 {
       // TodoScreen 상단에 TodoItemInputBackground와 TodoItem 추가
       TodoItemInputBackground(elevate = true, modifier = Modifier.fillMaxWidth()) {
           TodoItemInput(onItemComplete = onAddItem)
       }
...

TodoItemInput 사용

주요 컴포저블 UI를 정의했으므로 @Preview를 추가하는 것이 좋다. @Preview가 있으면 해당 컴포저블을 개별적으로 탐색할 수 있다.

TodoScreen.kt

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

이제 해당 컴포저블을 실행하여 별도로 디버그 할 수 있다. 사용자가 편집 가능한 텍스트 필드가 올바르게 표시되는 것을 볼 수 있다. 문자를 입력할 때마다 사용자에게 표시되는 TextField를 업데이트 하는 재구성을 트리거하는 상태가 업데이트된다.

PreviewTodoItemInput

item 추가 버튼 만들기

item 추가 버튼을 만들기 위해 TodoInputTextFieldtext에 접근해야 한다.

TodoItemInput의 구성 트리를 보면 TodoInputTextField 내부에 텍스트 상태를 저장하고 있음을 알 수 있다.

TodoItemInput 구성 트리
TodoItemInput Tree

이 구조에서는 onClicktext의 현재 값에 접근해야 하기 때문에 onClick을 연결할 수 없다. text 상태를 TodoItemInput에 노출하는 것과 동시에 단방향 데이터 흐름을 사용할 것이다.

단방향 데이터 흐름은 Jetpack Compose를 사용할 때 High-Level 아키텍처와 단일 컴포저블 설계에 모두 적용된다. 여기서 이벤트가 위로 흐르고 상태가 아래로 흐르도록 만들 것이다. 즉, TodoItemInput에서 상태가 아래로 흐르고 이벤트가 위로 흐르는 것을 의미한다.

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

Todo ItemInput Diagram

상태를 자식 컴포저블 TodoInputTextField에서 부모 컴포저블 TodoItemInput로 이동해야 한다.

상태 호이스팅이 있는 TodoItemInput 구성 트리 (내장 컴포저블 숨김)

이 패턴은 상태 호이스팅이라고 한다. stateless 상태로 만들기 위해 컴포저블에서 상태를 "호이스트"한다. 상태 호이스팅은 Compose에서 단방향 데이터 흐름 디자인을 구축하는 주요 패턴이다.

상태 호이스팅은 구성 요소를 stateless로 만들기 위해 상태를 위로 이동시키는 패턴이다. 컴포저블에 상태 호이스팅은 컴포저블에 두 개의 매개변수를 도입하는 것을 의미하는데

  • value: T : 표시할 현재 값
  • onValueChange: (T) -> Unit : 값 변경을 요청하는 이벤트

상태 호이스팅을 시작하려면 컴포저블의 내부 상태 T(value: T, onValueChange: (T) -> Unit) 매개변수 쌍으로 리팩토링할 수 있다.

(value, onValueChange) 매개변수를 추가하여 상태를 호이스트하도록 TodoInputTextField를 수정한다.

TodoScreen.kt

// 호이스트된 상태를 가진 TodoInputTextField
@Composable
fun TodoInputTextField(text: String, onTextChange: (String) -> Unit, modifier: Modifier) {
   TodoInputText(text, onTextChange, modifier)
}

TodoInputTextFieldvalueonValueChange 매개변수를 추가하는 코드이다. value 매개변수는 text이고 onValueChange 매개변수는 onTextChange이다.

이제 상태가 호이스트되었기 때문에 TodoInputTextField에서 remember된 상태를 제거한다.

이 방법으로 호이스트된 상태에는 중요한 속성들이 있는데,

  • Single source of truth 단일 진실 공급원 : 상태를 복제하는 대신 상태를 이동해 텍스트에 대한 단일 진실 공급원이 있는지 확인한다. 이는 버그를 피하는 데 도움이 된다.

  • Encapsulated 캡슐화 : TodoItemInput만 상태를 수정할 수 있지만 다른 구성 요소는 이벤트를 TodoItemInput에 보낼 수 있다. 이 방식으로 호이스팅하면 여러 컴포저블이 상태를 사용하더라도 하나의 컴포저블만 저장된다.

  • Shareable 공유 가능 : 호이스트된 상태는 여러 컴포저블과 함께 변경할 수 없는 값으로 공유될 수 있다. TodoInputTextFieldTodoEditButton에서 상태를 사용할 것이다.

  • Interceptable : TodoItemInput은 상태를 변경하기 전에 이벤트를 무시하거나 수정할 수 있다. 예를 들어, TodoItemInput은 사용자가 입력할 때 emoji-codes:emoji로 포맷화 할 수 있다.

  • Decoupled : TodoInputTextField의 상태는 어디에나 저장할 수 있다. 예를 들어, TodoInputTextField를 수정하지 않고 문자를 입력할 때마다 업데이트 되는 Room DB에서 상태를 백업하도록 선택할 수 있다.

이제 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 = {  },
               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() // 텍스트가 비어 있지 않은 경우 활성화
)

두 개의 다른 컴포저블에서 동일한 상태 변수인 text를 사용하고 있다. 상태를 올리면 이와 같은 상태를 공유할 수 있다. 그리고 TodoItemInputstateful 컴포저블로 만들면서 처리했다.

상태 기반 동적 UI

이번엔 상태를 기반으로 동적 UI를 구축해볼 것이다. 텍스트가 비어 있지 않을 때 아이콘 행을 그릴것이다.

Todo input(state:expanded-텍스트가 비어있지 않을 때)

Todo input expanded

Todo input(state:collapsed-텍스트가 비어있을 때)

Todo input collapsed

상태에서 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을 추가했다.

iconsVisible 값은 TodoItemInput에 새로운 상태를 추가하지 않는다. TodoItemInput이 직접 변경할 수 있는 방법은 없다. 대신 전적으로 text의 값을 기반으로 한다. 이 재구성에서 text 값이 무엇이든, iconsVisible은 그에 따라 설정되며 올바른 UI를 표시하는 데 사용할 수 있다.

아이콘이 visible되는 시점을 제어하기 위해 TodoItemInput에 다른 상태를 추가할 수 있지만 val iconsVisible = text.isNotBlank()를 보면 가시성이 입력된 테스트를 기반으로 한다고 볼 수 있다. 두 개의 상태를 만들면 동기화에서 벗어나기 쉬울 것이다.

iconsVisible의 값에 따라 AnimatedIconRow를 표시하도록 TodoItemInput을 수정할 것이다. iconsVisibletrueAnimatedIconRow를 표시하고 false16.dpSpacer를 표시한다.

TodoScreen.kt

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

재구성은 새로운 데이터를 기반으로 구성 트리의 구조를 변경할 수 있다. 여기서는 SpacerAnimatedIconRow를 교체할 것이다. Navigation 컴포저블에서와 같이 전체 트리를 변경할 수 있다.

지금 앱을 실행하면 텍스트를 입력할 때 아이콘이 움직인다. (animate) iconsVisible의 값을 기반으로 구성 트리를 동적으로 변경할 것이다.

iconsVisible이 변경되었을 때의 TodoItemInput 구성트리

Compose에는 "가시성"이 없다. Compose는 구성을 동적으로 변경할 수 있기 때문에 가시성을 없애도록 설정할 필요가 없다.

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

onClick 리스너에서 새 아이콘 상태를 사용하려면 TodoItemInput에서 TodoEditButton을 수정할 것이다.

TodoScreen.kt

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

onClick 리스너에서 직접 새 아이콘 상태를 사용할 수 있다. 사용자가 TodoItem 입력을 완료하면 기본값으로 재설정한다.

imeAction으로 디자인 마무리

키보드의 ime 작업에서 todo item을 제출해야 한다고 알려준다. 주로 키보드 오른쪽 하단의 파란 버튼이다.

ImeAction.Done이 있는 Android 키보드

Keyboard

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

우리는 onImeActionTodoEditButton과 같은 행동을 하도록 만들 것이다. 같은 코드를 사용할 수 있지만 유지 관리가 어려워진다.

이벤트를 변수로 추출하여 TodoInputTextonImeActionTodoEditButtononClick 모두에 사용할 수 있다.

TodoItemInput을 다시 수정해 submit 작업을 수행하는 사용자를 처리하는 새 람다 함수 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 // submit 콜백을 TodoInputText에 전달
           )
           TodoEditButton(
               onClick = submit, // submit 콜백을 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))
       }
   }
}

키보드 작업을 처리하기 위해 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
    )
}

0개의 댓글