Jetpack Compose의 상태(3) - State Holder

Seogi·2025년 2월 23일

Compose

목록 보기
5/8

상태 홀더란?

공식 문서에서는 상태 홀더를 이렇게 설명한다.

컴포저블의 로직과 상태를 관리한다.
다른 자료에서는, 상태 홀더를 호이스팅한 상태 객체라고도 한다.

상태를 끌어올리는 것은 컴포저블을 Stateless하게 만드는 과정이다.
이때, 추적해야 할 상태의 양이 늘어나거나 컴포저블에 상태에 따른 로직이 많아지는 경우 로직과 상태 책임을 다른 클래스로 위임하는 것이 좋다.
이 클래스를 상태 홀더라고 하는 것이다.

글로만 보면 무슨 말인지 잘 와 닿지 않는다.

예제를 통해서 알아보도록 하자.


공통 컴포넌트로 사용할 Custom TextField가 있다.

@Composable
internal fun ExampleScreen(modifier: Modifier = Modifier) {
    var text by rememberSaveable { mutableStateOf("") }
    var isError = text.any { it.isUpperCase() }

    ExampleTextField(
        modifier = modifier.padding(30.dp),
        value = text,
        onValueChange = { text = it },
        placeholder = "안녕하세요",
        maxLength = 10,
        isError = isError,
        isLengthVisible = true,
    )
}

@Composable
internal fun ExampleTextField(
    modifier: Modifier = Modifier,
    value: String,
    onValueChange: (String) -> Unit,
    placeholder: String = "",
    maxLength: Int? = null,
    isError: Boolean = false,
    isLengthVisible: Boolean = false,
) {
    var isFocused by remember { mutableStateOf(false) }

    val textColor = when {
        isError -> Color.Red
        isFocused -> Color.Black
        else -> Color.Gray
    }

    TextField(
        modifier = modifier
            .fillMaxWidth()
            .onFocusChanged { isFocused = it.isFocused },
        value = value,
        onValueChange = {
            if (maxLength == null || it.length <= maxLength) {
                onValueChange(it)
            }
        },
        textStyle = TextStyle.Default.copy(color = textColor),
        placeholder = {
            if (placeholder.isNotBlank() && value.isBlank() && !isFocused) {
                Text(
                    text = placeholder,
                    maxLines = 1,
                    style = TextStyle.Default.copy(color = Color.Gray),
                )
            }
        },
        trailingIcon = {
            if (isLengthVisible && maxLength != null) {
                Text(
                    text = "${value.length} / $maxLength",
                    color = Color.Gray
                )
            }
        }
    )
}

ExampleTextField 컴포넌트는 상태 호이스팅을 적용한 모습이다.
(valueonValueChange가 argument로 들어있는 것을 볼 수 있다)

그 외의 argument들에 대해서 설명하자면

  • placeholder
  • maxLength: 입력 최대 길이
  • isError: 입력한 텍스트의 에러 여부
  • isLengthVisible: 입력창 우측에 현재 텍스트 길이를 나타낼지 여부 (ex. 5/10)

ExampleTextField 이 상태 외에도 여러 로직을 가지고 있다. 지금도 그렇게 간단해 보이지 않지만, 컴포넌트가 더 많은 기능이 추가되고, 그에 따라 상태와 로직들이 늘어난다면 관리에 어려움이 생길 것이다.

컴포저블이 여러 상태와 로직을 가지고 있거나 가질 것으로 예상된다면 상태 홀더를 만드는 것을 추천한다.

상태 홀더 만들기

ExampleTextFieldState 라는 일반 클래스로 상태 홀더를 만든다.

class ExampleTextFieldState(
    initialText: String = "",
    isLengthVisible: Boolean = false,
    val placeholder: String? = null,
    val maxLength: Int? = null,
    val validation: (String) -> Boolean = { true },
) {
    var text by mutableStateOf(initialText)
        private set

    val isError: Boolean
        get() = !validation(text)

    val isLengthVisible: Boolean = isLengthVisible && maxLength != null

    val isPlaceHolderVisible: Boolean
        get() = placeholder != null && text.isBlank()

    fun updateText(newText: String) {
        if (isTextLengthExceed(newText, maxLength)) {
            return
        }
        text = newText
    }

    private fun isTextLengthExceed(newText: String, maxLength: Int?) =
        maxLength != null && newText.length > maxLength
}

위 클래스는 다음과 같은 특성이 있다.

  • text는 Compose가 값의 변경을 추적할 수 있도록 mutableStateOf()를 사용해 감싸주었다.
  • 외부에서 직접 수정할 수 없도록 private set을 해주고 updateText를 이용해 수정할 수 있도록 해주었다.즉, updateText가 우리가 기존의 onValueChange라 생각하면 된다.
  • 이전에는 텍스트의 길이 제한이 있을 때 조건문을 두고, 유효한 경우 onValueChange를 해주었지만, 이 과정을 updateText()로 캡슐화해주었다.
  • 상태(text)에 따라 변하는 값들에 대해서는 Computed Property를 활용해주었다.

상태 홀더를 적용한 코드는 아래와 같다.

@Composable
internal fun ExampleScreen(modifier: Modifier = Modifier) {
    val exampleTextFieldState = remember {
        ExampleTextFieldState(
            initialText = "",
            isLengthVisible = true,
            validation = { it.all { it.isLowerCase() }},
            placeholder = "텍스트를 입력해주세요.",
            maxLength = 15,
        )
    }

    ExampleTextField(
        modifier = modifier.padding(30.dp),
        state = exampleTextFieldState,
    )
}

internal fun ExampleTextField(
    modifier: Modifier = Modifier,
    state: ExampleTextFieldState,
) {
    var isFocused by remember { mutableStateOf(false) }

    val textColor = when {
        state.isError -> Color.Red
        isFocused -> Color.Black
        else -> Color.Gray
    }

    TextField(
        modifier = modifier
            .fillMaxWidth()
            .onFocusChanged { isFocused = it.isFocused },
        value = state.text,
        onValueChange = { state.updateText(it) },
        textStyle = TextStyle.Default.copy(color = textColor),
        placeholder = {
            if (state.isPlaceHolderVisible && !isFocused) {
                Text(
                    text = state.placeholder ?: return@TextField,
                    maxLines = 1,
                    style = TextStyle.Default.copy(color = Color.Gray),
                )
            }
        },
        trailingIcon = {
            if (state.isLengthVisible) {
                Text(
                    text = "${state.text.length}/${state.maxLength ?: return@TextField}",
                    color = Color.Gray
                )
            }
        }
    )
}

컴포저블 내부 상태를 담당하는 상태홀더를 만들어 모든 상태 변경을 한 곳으로 중앙화 할 수 있다.

이렇게 되면 상태가 쉽게 동기화되고 관련 로직들도 단일 클래스로 그룹화되면서 유지보수 측면에서도 장점이 있다.

또한, 내부의 구현도 간결해 지면서 가독성이 올라가고, 해당 클래스(상태 홀더)를 재활용할 수 있기에 개발 효율이 높아질 것이다.

구성 변경 대응 (상태 홀더 기억하기)

위에서 만든 상태 홀더는 구성 변경 시 객체를 새로 만들게 되고 이전 상태들을 기억하지 못한다는 문제가 있다.

rememberSavable을 사용하면 되는 거 아니야?” 라고 할 수 있지만 반은 맞고, 반은 틀리다.

우선, 상태 홀더는 객체이다. rememberSavable은 Bundle에 값을 넣어 구성변경에 대응하는 방식이고 Bundle에는 Primitive 값만이 들어갈 수 있다. 따라서 추가적인 조치가 필요하다.

Bundle에 추가할 수 없는 항목을 저장하려는 경우에는 세 가지 방법이 있다.

  1. Parcelize
  2. MapSaver
  3. ListSaver

자세한 내용은 이 글을 참고

그런데 여기서 까다로운 부분이 한 가지 더 존재한다.

validation의 타입이 람다라는 것이다.

참고로 예제 코드에서 Parcelize사용할 경우 아래와 같은 에러로 인해 Parcelize를 사용하기에는 적절치 않다.

‘Parcelable' constructor parameter should be 'val' or 'var’

여러 방법이 있을 수 있겠지만 나의 방법을 소개해보겠다.

우선, 코드는 아래와 같다.

class ExampleTextFieldState(
    initialText: String = "",
    isLengthVisible: Boolean = false,
    val placeholder: String? = null,
    val maxLength: Int? = null,
    val validation: (String) -> Boolean = { true },
) {
    var text by mutableStateOf(initialText)
        private set

    val isError: Boolean
        get() = !validation(text)

    val isLengthVisible: Boolean = isLengthVisible && maxLength != null

    val isPlaceHolderVisible: Boolean
        get() = placeholder != null && text.isBlank()

    fun updateText(newText: String) {
        if (isTextLengthExceed(newText, maxLength)) {
            return
        }
        text = newText
    }

    private fun isTextLengthExceed(newText: String, maxLength: Int?) =
        maxLength != null && newText.length > maxLength

    companion object {
        val Saver: Saver<ExampleTextFieldState, *> = listSaver(
            save = {
                listOf(
                    it.text,
                    it.isLengthVisible,
                    it.placeholder,
                    it.maxLength,
                    Validation(it.validation)
                )
            },
            restore = {
                ExampleTextFieldState(
                    initialText = it[0] as String,
                    isLengthVisible = it[1] as Boolean,
                    placeholder = it[2] as String?,
                    maxLength = it[3] as Int?,
                    validation = (it[4] as Validation).validation,
                )
            }
        )
    }
}

@Parcelize
class Validation(val validation: (String) -> Boolean) : Parcelable

@Composable
fun rememberExampleTextFieldState(
    initialText: String = "",
    isLengthVisible: Boolean = false,
    placeholder: String? = null,
    maxLength: Int? = null,
    validation: (String) -> Boolean = { true },
): ExampleTextFieldState {
    return rememberSaveable(saver = ExampleTextFieldState.Saver) {
        ExampleTextFieldState(
            initialText,
            isLengthVisible,
            placeholder,
            maxLength,
            validation
        )
    }
}

보면 알겠지만, 나의 해결법은 특정 부분만 Parcelable 객체로 바꿔준 후 Bundle에 저장하는 방법이다.

그러기 위해서 Validation이라는 validation을 저장하기 위한 클래스를 추가로 작성해 주었다.

(혹시 더 좋은 방법이 있다면 댓글로 알려주세요!)

그 후 rememberExampleTextFieldState라는 컴포저블을 만들어 구성 변경에 대응할 수 있는 상태 홀더를 사용할 수 있도록 해주었다.


최종적으로 ExampleScreen은 아래와 같이 변경된다.

@Composable
fun ExampleScreen(modifier: Modifier = Modifier) {
    val exampleTextFieldState: ExampleTextFieldState = rememberExampleTextFieldState(
        initialText = "",
        isLengthVisible = true,
        validation = { it.all { it.isLowerCase() } },
        placeholder = "텍스트를 입력해주세요.",
        maxLength = 15,
    )

    ExampleTextField(
        modifier = modifier.padding(30.dp),
        state = exampleTextFieldState,
    )
}

0개의 댓글