상태 기반 텍스트 필드로 이전

이 페이지에서는 값 기반 TextField를 상태 기반 TextField로 이전하는 방법의 예를 제공합니다. 값 기반 TextField와 상태 기반 TextField의 차이점에 관한 자세한 내용은 텍스트 필드 구성 페이지를 참고하세요.

기본 사용법

가치 기반

@Composable
fun OldSimpleTextField() {
    var state by rememberSaveable { mutableStateOf("") }
    TextField(
        value = state,
        onValueChange = { state = it },
        singleLine = true,
    )
}

상태 기반

@Composable
fun NewSimpleTextField() {
    TextField(
        state = rememberTextFieldState(),
        lineLimits = TextFieldLineLimits.SingleLine
    )
}

  • value, onValueChangeremember { mutableStateOf("") }를 rememberTextFieldState()로 바꿉니다.
  • singleLine = truelineLimits = TextFieldLineLimits.SingleLine로 대체했습니다.

onValueChange를 통한 필터링

가치 기반

@Composable
fun OldNoLeadingZeroes() {
    var input by rememberSaveable { mutableStateOf("") }
    TextField(
        value = input,
        onValueChange = { newText ->
            input = newText.trimStart { it == '0' }
        }
    )
}

상태 기반

@Preview
@Composable
fun NewNoLeadingZeros() {
    TextField(
        state = rememberTextFieldState(),
        inputTransformation = InputTransformation {
            while (length > 0 && charAt(0) == '0') delete(0, 1)
        }
    )
}

  • 값 콜백 루프를 rememberTextFieldState()로 바꿉니다.
  • InputTransformation를 사용하여 onValueChange에서 필터링 로직을 다시 구현합니다.
  • InputTransformation의 수신기 범위에서 TextFieldBuffer를 사용하여 state를 업데이트합니다.
    • InputTransformation는 사용자 입력이 감지된 직후에 호출됩니다.
    • InputTransformation에서 TextFieldBuffer를 통해 제안하는 변경사항은 즉시 적용되므로 소프트웨어 키보드와 TextField 간의 동기화 문제가 방지됩니다.

신용카드 형식 지정자 TextField

가치 기반

@Composable
fun OldTextFieldCreditCardFormatter() {
    var state by remember { mutableStateOf("") }
    TextField(
        value = state,
        onValueChange = { if (it.length <= 16) state = it },
        visualTransformation = VisualTransformation { text ->
            // Making XXXX-XXXX-XXXX-XXXX string.
            var out = ""
            for (i in text.indices) {
                out += text[i]
                if (i % 4 == 3 && i != 15) out += "-"
            }

            TransformedText(
                text = AnnotatedString(out),
                offsetMapping = object : OffsetMapping {
                    override fun originalToTransformed(offset: Int): Int {
                        if (offset <= 3) return offset
                        if (offset <= 7) return offset + 1
                        if (offset <= 11) return offset + 2
                        if (offset <= 16) return offset + 3
                        return 19
                    }

                    override fun transformedToOriginal(offset: Int): Int {
                        if (offset <= 4) return offset
                        if (offset <= 9) return offset - 1
                        if (offset <= 14) return offset - 2
                        if (offset <= 19) return offset - 3
                        return 16
                    }
                }
            )
        }
    )
}

상태 기반

@Composable
fun NewTextFieldCreditCardFormatter() {
    val state = rememberTextFieldState()
    TextField(
        state = state,
        inputTransformation = InputTransformation.maxLength(16),
        outputTransformation = OutputTransformation {
            if (length > 4) insert(4, "-")
            if (length > 9) insert(9, "-")
            if (length > 14) insert(14, "-")
        },
    )
}

  • onValueChange의 필터링을 InputTransformation로 바꾸어 입력의 최대 길이를 설정합니다.
  • VisualTransformationOutputTransformation로 바꾸면 대시가 추가됩니다.
    • VisualTransformation를 사용하면 대시로 새 텍스트를 만들고 시각적 텍스트와 백킹 상태 간에 색인이 매핑되는 방식을 계산해야 합니다.
    • OutputTransformation는 오프셋 매핑을 자동으로 처리합니다. OutputTransformation.transformOutput의 수신기 범위에서 TextFieldBuffer를 사용하여 올바른 위치에 대시를 추가하기만 하면 됩니다.

상태 업데이트 (간단)

가치 기반

@Composable
fun OldTextFieldStateUpdate(userRepository: UserRepository) {
    var username by remember { mutableStateOf("") }
    LaunchedEffect(Unit) {
        username = userRepository.fetchUsername()
    }
    TextField(
        value = username,
        onValueChange = { username = it }
    )
}

상태 기반

@Composable
fun NewTextFieldStateUpdate(userRepository: UserRepository) {
    val usernameState = rememberTextFieldState()
    LaunchedEffect(Unit) {
        usernameState.setTextAndPlaceCursorAtEnd(userRepository.fetchUsername())
    }
    TextField(state = usernameState)
}

  • 값 콜백 루프를 rememberTextFieldState()로 바꿉니다.
  • TextFieldState.setTextAndPlaceCursorAtEnd로 값 할당을 변경합니다.

상태 업데이트 (복잡)

가치 기반

@Composable
fun OldTextFieldAddMarkdownEmphasis() {
    var markdownState by remember { mutableStateOf(TextFieldValue()) }
    Button(onClick = {
        // add ** decorations around the current selection, also preserve the selection
        markdownState = with(markdownState) {
            copy(
                text = buildString {
                    append(text.take(selection.min))
                    append("**")
                    append(text.substring(selection))
                    append("**")
                    append(text.drop(selection.max))
                },
                selection = TextRange(selection.min + 2, selection.max + 2)
            )
        }
    }) {
        Text("Bold")
    }
    TextField(
        value = markdownState,
        onValueChange = { markdownState = it },
        maxLines = 10
    )
}

상태 기반

@Composable
fun NewTextFieldAddMarkdownEmphasis() {
    val markdownState = rememberTextFieldState()
    LaunchedEffect(Unit) {
        // add ** decorations around the current selection
        markdownState.edit {
            insert(originalSelection.max, "**")
            insert(originalSelection.min, "**")
            selection = TextRange(originalSelection.min + 2, originalSelection.max + 2)
        }
    }
    TextField(
        state = markdownState,
        lineLimits = TextFieldLineLimits.MultiLine(1, 10)
    )
}

이 사용 사례에서는 버튼이 마크다운 장식을 추가하여 커서 또는 현재 선택사항 주변의 텍스트를 굵게 표시합니다. 또한 변경 후 선택 위치를 유지합니다.

  • 값 콜백 루프를 rememberTextFieldState()로 바꿉니다.
  • maxLines = 10lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10)로 대체했습니다.
  • TextFieldState.edit 호출을 사용하여 새 TextFieldValue를 계산하는 로직을 변경합니다.
    • 현재 선택을 기반으로 기존 텍스트를 스플라이스하고 그 사이에 마크다운 장식을 삽입하여 새 TextFieldValue가 생성됩니다.
    • 또한 선택은 텍스트의 새 색인에 따라 조정됩니다.
    • TextFieldState.edit에는 TextFieldBuffer를 사용하여 현재 상태를 더 자연스럽게 수정하는 방법이 있습니다.
    • 선택사항은 장식을 삽입할 위치를 명시적으로 정의합니다.
    • 그런 다음 onValueChange 접근 방식과 마찬가지로 선택을 조정합니다.

ViewModel StateFlow 아키텍처

많은 애플리케이션은 StateFlow를 사용하여 모든 정보를 전달하는 단일 불변 클래스를 통해 화면 또는 구성요소의 UI 상태를 정의하는 것을 권장하는 최신 앱 개발 가이드라인을 따릅니다.

이러한 유형의 애플리케이션에서는 텍스트 입력이 있는 로그인 화면과 같은 양식이 일반적으로 다음과 같이 설계됩니다.

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState>
        get() = _uiState.asStateFlow()

    fun updateUsername(username: String) = _uiState.update { it.copy(username = username) }

    fun updatePassword(password: String) = _uiState.update { it.copy(password = password) }
}

data class UiState(
    val username: String = "",
    val password: String = ""
)

@Composable
fun LoginForm(
    loginViewModel: LoginViewModel,
    modifier: Modifier = Modifier
) {
    val uiState by loginViewModel.uiState.collectAsStateWithLifecycle()
    Column(modifier) {
        TextField(
            value = uiState.username,
            onValueChange = { loginViewModel.updateUsername(it) }
        )
        TextField(
            value = uiState.password,
            onValueChange = { loginViewModel.updatePassword(it) },
            visualTransformation = PasswordVisualTransformation()
        )
    }
}

이 디자인은 value, onValueChange 상태 호이스팅 패러다임을 사용하는 TextFields에 완벽하게 적합합니다. 그러나 텍스트 입력의 경우 이 접근 방식에는 예측할 수 없는 단점이 있습니다. 이 접근 방식의 심층 동기화 문제는 Compose에서 TextField의 효과적인 상태 관리 블로그 게시물에서 자세히 설명합니다.

문제는 새 TextFieldState 디자인이 StateFlow 지원 ViewModel UI 상태와 직접 호환되지 않는다는 점입니다. TextFieldState는 본질적으로 변경 가능한 데이터 구조이므로 username: Stringpassword: Stringusername: TextFieldStatepassword: TextFieldState로 대체하는 것이 이상해 보일 수 있습니다.

일반적으로 UI 종속 항목을 ViewModel에 배치하지 않는 것이 좋습니다. 이는 일반적으로 좋은 방법이지만 때로는 오해의 소지가 있습니다. 이는 TextFieldState와 같이 순수한 데이터 구조이며 UI 요소를 포함하지 않는 Compose 종속 항목에 특히 적용됩니다.

MutableState 또는 TextFieldState와 같은 클래스는 Compose의 스냅샷 상태 시스템에 의해 지원되는 간단한 상태 홀더입니다. StateFlow 또는 RxJava와 같은 종속 항목과 다르지 않습니다. 따라서 코드에서 'ViewModel에 UI 종속 항목 없음' 원칙을 적용하는 방법을 재평가하는 것이 좋습니다. ViewModel 내에 TextFieldState 참조를 유지하는 것은 본질적으로 나쁜 방법이 아닙니다.

UiState에서 username 또는 password와 같은 값을 추출하고 ViewModel에 별도의 참조를 유지하는 것이 좋습니다.

class LoginViewModel : ViewModel() {
    val usernameState = TextFieldState()
    val passwordState = TextFieldState()
}

@Composable
fun LoginForm(
    loginViewModel: LoginViewModel,
    modifier: Modifier = Modifier
) {
    Column(modifier) {
        TextField(state = loginViewModel.usernameState,)
        SecureTextField(state = loginViewModel.passwordState)
    }
}

  • MutableStateFlow<UiState>를 몇 개의 TextFieldState 값으로 바꿉니다.
  • 이러한 TextFieldState 객체를 LoginForm 컴포저블의 TextFields에 전달합니다.

규정 준수 접근 방식

이러한 유형의 아키텍처 변경은 항상 쉽지 않습니다. 이러한 변경사항을 자유롭게 적용할 수 없거나 새 TextField를 사용하는 데 드는 시간이 이점보다 클 수 있습니다. 이 경우 약간의 조정으로 상태 기반 텍스트 필드를 계속 사용할 수 있습니다.

class LoginViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState>
        get() = _uiState.asStateFlow()

    fun updateUsername(username: String) = _uiState.update { it.copy(username = username) }

    fun updatePassword(password: String) = _uiState.update { it.copy(password = password) }
}

data class UiState(
    val username: String = "",
    val password: String = ""
)

@Composable
fun LoginForm(
    loginViewModel: LoginViewModel,
    modifier: Modifier = Modifier
) {
    val initialUiState = remember(loginViewModel) { loginViewModel.uiState.value }
    Column(modifier) {
        val usernameState = rememberTextFieldState(initialUiState.username)
        LaunchedEffect(usernameState) {
            snapshotFlow { usernameState.text.toString() }.collectLatest {
                loginViewModel.updateUsername(it)
            }
        }
        TextField(usernameState)

        val passwordState = rememberTextFieldState(initialUiState.password)
        LaunchedEffect(usernameState) {
            snapshotFlow { usernameState.text.toString() }.collectLatest {
                loginViewModel.updatePassword(it)
            }
        }
        SecureTextField(passwordState)
    }
}

  • ViewModelUiState 클래스를 동일하게 유지합니다.
  • 상태를 ViewModel에 직접 호이스팅하고 TextFields의 소스 저장소로 만드는 대신 ViewModel를 간단한 데이터 홀더로 전환합니다.
    • 이렇게 하려면 LaunchedEffect에서 snapshotFlow를 수집하여 각 TextFieldState.text의 변경사항을 관찰합니다.
  • ViewModel에는 여전히 UI의 최신 값이 있지만 uiState: StateFlow<UiState>TextField를 구동하지 않습니다.
  • ViewModel에 구현된 다른 모든 지속성 로직은 동일하게 유지할 수 있습니다.