본문으로 바로가기

[Compose] 11. State holder

category 개발이야기/Android 2021. 10. 11. 01:54
반응형

 

Stateful 한 Composable이 존재할 때 state hoisting을 하기 위한 pattern과 이에 대한 장점에 대해서는 앞서 얘기했습니다. [1] Composable에 state가 여러 개 존재하는 경우 관리를 쉽게 하기 위하여 한 곳에 모아놓는걸 State holder라고 하며, 이럴 경우 Caller에서 상태를 좀 더 쉽게 제어할 수도 있습니다.

이외에도 연관있는 상태들을 한 번에 묶이므로 state hoisting이 좀 더 유연해지며, composable의 재활용성도 올라갑니다.

기본 예제

단순하게 입력을 처리하는 TextField를 아래와 같이 작성했습니다.

@Composable
fun FavoriteFoodInput(onFavoriteFoodInputChanged: (String) -> Unit) {
    Surface(color = Color.Gray) {
        Row(modifier = Modifier.fillMaxWidth()) {
            Icon(Icons.Filled.Favorite, contentDescription = "")
            Spacer(modifier = Modifier.padding(4.dp))
            FoodEditableInput(
                Modifier.align(Alignment.CenterVertically),
                "Write your favorite food?"
            ) { onFavoriteFoodInputChanged(it) }
        }
    }
}

@Composable
fun FoodEditableInput(modifier: Modifier = Modifier, hint: String,
                                 onInputChanged: (String) -> Unit) {
    var text by remember { mutableStateOf(hint) }
    val isHint = { text == hint }

    BasicTextField(
        modifier = modifier,
        value = text,
        onValueChange = {
            text = it
            onInputChanged(it)
        },
        textStyle = if (isHint()) {
            MaterialTheme.typography.caption.copy(color = Color.LightGray)
        } else {
            MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
        },
        cursorBrush = SolidColor(LocalContentColor.current)
    )
}

현재는 FoodEditableInput이라는 Composable 내부에 state가 존재 합니다. 또한 hint를 체크하는 부분 역시 text와 연관되어 있으므로, hoisting시 두 개의 state는 같이 옮겨져야 합니다.

Creating the state holder

class FoodEditableInputState(private val hint: String, initailText: String) {
    var text by mutableStateOf(initailText)
    val isHint: Boolean get() = text == hint
   ...
}

상태를 가지고 있을 하나의 class를 만듭니다.  FoodEditableInputState는 내부에 mutableState 타입으로 text 상태변수를 저장하며, text의 값을 이용하여 hint 여부를 확인하는 멤버 변수를 위치시킵니다.

text 타입과 isHint 모두 공개된 타입으로 바로 접근해서 사용할수 있습니다. 특히나 text 값은 외부에서 직접 입력이 가능한 형태로 작성합니다.

Remembering the state holder

@Composable
fun rememberFoodEditableInputState(hint: String): FoodEditableInputState {
    retrun remember(hint) {
        FoodEditableInputState(hint, hint)
    }
}

또한 이 클래스를 생성하여 remember 타입으로 반환하는 전용 Composable 함수(remeberFoodEditableInputState())도 추가합니다. 실제 remember 생성 함수와 class는 이 state holder를 사용하는 composable과 동일한 kt 파일에 모두 위치시켜  추후 코드가 잘못 분산되거나, 변경 시 알 수 있도록 합니다.

Createing a custom saver

Orienataion시에도 문제가 없도록 rememberremeberSaveable로 변경해 보도록 합니다. 먼저 holder class를 저장할 saver를 ListSaver로 만듭니다.[1] 이 saver는 FoodEditableInputState class만을 위한것이므로 class 내부에 companion object로 만들어 넣습니다.

class FoodEditableInputState(private val hint: String, initailText: String) {
    var text by mutableStateOf(initailText)
    val isHint: Boolean get() = text == hint

    companion object {
        val Saver: Saver<FoodEditableInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                FoodEditableInputState(hint = it[0], initailText = it[1])
            }
        )
    }
}

이제 remember를 반환해 주는 함수에서 remeberSaveable를 반환해 주도록 재수정 합니다.

@Composable
fun rememberFoodEditableInputState(hint: String): FoodEditableInputState {
    return rememberSaveable(hint, saver = FoodEditableInputState.Saver) {
        FoodEditableInputState(hint, hint)
    }
}

Using the state holder

이제 준비는 끝났으니 기존 코드에 만들어 놓은 코드를 호출하도록 변경합니다. 애초에 text를 hoisting 하는 게 목적이었기 때문에 기본적인 hoisting 방법을 사용해도 되지만 state holder를 생성했으니 기존 state 관련 부분을 제거하고 param에 이 holder를 넘겨받도록 변경합니다.

@Composable
fun FoodEditableInput(
    modifier: Modifier = Modifier,
    state: FoodEditableInputState = rememberFoodEditableInputState("")
) {
    BasicTextField(
        modifier = modifier,
        value = state.text,
        onValueChange = { state.text = it },
        textStyle = if (state.isHint) {
            MaterialTheme.typography.caption.copy(color = Color.LightGray)
        } else {
            MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
        },
        cursorBrush = SolidColor(LocalContentColor.current)
    )
}

물론 코드 내부에서 state에 접근했던 부분도 param으로 넘겨받은 state의 멤버 변수에 직접 접근하여 사용하도록 변경했습니다.

State holder callers

이제 FoodEditableInput composable의 caller인 FavoriteFoodInput composable을 수정해야 합니다. 상태를 param으로 넘기도록  바꿨으니, caller인 FavoriteFoodInput에서 직접 state를 생성하여 param으로 넣어 주도록 합니다.

@Composable
fun FavoriteFoodInput(onFavoriteFoodInputChanged: (String) -> Unit) {
    val foodEditableInputState = rememberFoodEditableInputState(
                                            hint = "Write your favorite food?")
        ...
            FoodEditableInput(
                Modifier.align(Alignment.CenterVertically),
                foodEditableInputState
            )
        }
    }

  ...
}

호출하는 부분에 넘겨줄 state를 FavoriteFoodInput()에서 rememberFoodEditableInputState()를 이용하여 생성합니다. 그리고 생성된 state를 param으로 넘겨줍니다.

다만 여기서, state가 holder로 들어가면서 변경점이 발생할 때 호출해 주는 onFavoriteFoodInputChanged()의 호출 부분이 사라졌습니다. 이를 처리하기 위해서는 effect API인 LaunchedEffect를 이용하여 coroutine scope을 만들고 snapshotFlow를 통하여 state의 변경을 체크하도록 하여 아래와 같이 변경해 줄 수 있습니다. [3]

@InternalCoroutinesApi
@Composable
fun FavoriteFoodInput(onFavoriteFoodInputChanged: (String) -> Unit) {
    val foodEditableInputState = rememberFoodEditableInputState(hint = "Write your favorite food?")
    Surface(color = Color.Gray) {
        Row(modifier = Modifier.fillMaxWidth()) {
            Icon(Icons.Filled.Favorite, contentDescription = "")
            Spacer(modifier = Modifier.padding(4.dp))
            FoodEditableInput1(
                Modifier.align(Alignment.CenterVertically),
                foodEditableInputState
            )
        }
    }

    val currentFavoriteFoodInputChanged by rememberUpdatedState(onFavoriteFoodInputChanged)
    LaunchedEffect(foodEditableInputState) {
        snapshotFlow { foodEditableInputState.text }
            .filter { !foodEditableInputState.isHint }
            .collect {
                currentFavoriteFoodInputChanged(foodEditableInputState.text)
            }
    }
}

전체 코드는 위와 같습니다.

사실 이 코드를 보다 보면 이전에 사용했던 예제 중에 scroll을 위해 상태를 따로 가져와서 LazyColumn에 넣어주었던 코드들이 생각 날 수 있습니다. 아래는 이전에 사용했던 간단한 Lazycolumn 예제입니다.

fun SimpleList() {
    Box {
        val listSize = 100
        // 스크롤의 position의 상태를 저장.
        val scrollState = rememberLazyListState()

        Column(Modifier.fillMaxWidth()) {
            LazyColumn(
                state = scrollState,
                contentPadding = PaddingValues(bottom = 50.dp)
            ) {
                items(listSize) {
                    ImageListItem(it)
                }
            }
        }
        val showButton = remember {
            derivedStateOf {
                scrollState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton.value, modifier = Modifier.align(Alignment.BottomEnd)) {
            ScrollToTopButton(scrollState)
        }
    }
}

여기서 remeberLazyListState() 역시 state holder로 만들어진 LazyListState를 반환하는 역할을 합니다. 또한 첫 번째 요소가 보이는지에 대한 상태 역시 이 클래스 내부에 모아져 있음을 유추해 볼 수 있습니다.

References

[1] 2021.09.19 - [개발이야기/Android] - [Compose] 4. 상태관리 - hoisting, mutableState, remember, rememberSaveable, Parcelize, MapSaver, ListSaver

[2] https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects?continue=https%3A%2F%2Fdeveloper.android.com%2Fcourses%2Fpathways%2Fcompose%23codelab-https%3A%2F%2Fdeveloper.android.com%2Fcodelabs%2Fjetpack-compose-advanced-state-side-effects#5

[3] 2021.09.25 - [개발이야기/Android] - [Compose] 6. Side-effects - LaunchedEffect, rememberCoroutineScope, rememberUpdatedState, DisposableEffect, SideEffect, produceState, derivedStateOf, snapshotFlow

 

반응형