Migracja do pól tekstowych zależnych od stanu

Na tej stronie znajdziesz przykłady migracji TextField opartych na wartościach do TextField opartych na stanie. Więcej informacji o różnicach między TextField opartymi na wartościach i stanach znajdziesz na stronie Konfigurowanie pól tekstowych.

Podstawowe użycie

Na podstawie wartości

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

Zależne od stanu

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

  • Zastąp value, onValueChangeremember { mutableStateOf("") } tekstem rememberTextFieldState().
  • Zastąp singleLine = true tekstem lineLimits = TextFieldLineLimits.SingleLine.

Filtrowanie według: onValueChange

Na podstawie wartości

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

Zależne od stanu

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

  • Zastąp pętlę wywołania zwrotnego wartością rememberTextFieldState().
  • Zaimplementuj ponownie logikę filtrowania w onValueChange za pomocą InputTransformation.
  • Użyj TextFieldBuffer z zakresu odbiornika InputTransformation, aby zaktualizować state.
    • InputTransformation jest wywoływana natychmiast po wykryciu interakcji użytkownika.
    • Zmiany proponowane przez InputTransformation za pomocąTextFieldBuffer są stosowane natychmiast, co pozwala uniknąć problemu z synchronizacją między klawiaturą programową a TextField.

Formatowanie karty kredytowej TextField

Na podstawie wartości

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

Zależne od stanu

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

  • Zastąp filtrowanie w onValueChange znakiem InputTransformation, aby ustawić maksymalną długość danych wejściowych.
  • Aby dodać łączniki, zastąp VisualTransformation kodem OutputTransformation.
    • W przypadku VisualTransformation odpowiadasz zarówno za utworzenie nowego tekstu z myślnikami, jak i za obliczenie, jak indeksy są mapowane między tekstem wizualnym a stanem bazowym.
    • OutputTransformation automatycznie zajmuje się mapowaniem przesunięć. Wystarczy, że dodasz myślniki w odpowiednich miejscach, korzystając z TextFieldBuffer z zakresu odbiornika OutputTransformation.transformOutput.

Aktualizowanie stanu (proste)

Na podstawie wartości

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

Zależne od stanu

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

  • Zastąp pętlę wywołania zwrotnego wartością rememberTextFieldState().
  • Zmień przypisanie wartości za pomocą TextFieldState.setTextAndPlaceCursorAtEnd.

Aktualizowanie stanu (złożone)

Na podstawie wartości

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

Zależne od stanu

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

W tym przypadku przycisk dodaje dekoracje Markdown, aby pogrubić tekst wokół kursora lub bieżącego zaznaczenia. Zachowuje też pozycję zaznaczenia po wprowadzeniu zmian.

  • Zastąp pętlę wywołania zwrotnego wartością rememberTextFieldState().
  • Zastąp maxLines = 10 tekstem lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10).
  • Zmień logikę obliczania nowego parametru TextFieldValue za pomocą wywołania TextFieldState.edit.
    • Nowy TextFieldValue jest generowany przez połączenie istniejącego tekstu na podstawie bieżącego zaznaczenia i wstawienie między nimi dekoracji Markdown.
    • Zaznaczenie jest też dostosowywane do nowych indeksów tekstu.
    • TextFieldState.edit ma bardziej naturalny sposób edytowania bieżącego stanu za pomocą TextFieldBuffer.
    • Wybór wyraźnie określa, gdzie wstawić dekoracje.
    • Następnie dostosuj wybór podobnie jak w przypadku metody onValueChange.

Architektura ViewModel StateFlow

Wiele aplikacji jest zgodnych z wytycznymi dotyczącymi nowoczesnego tworzenia aplikacji, które zalecają używanie StateFlow do definiowania stanu interfejsu ekranu lub komponentu za pomocą jednej niezmiennej klasy zawierającej wszystkie informacje.

W tego typu aplikacjach formularz, np. ekran logowania z polem tekstowym, jest zwykle zaprojektowany w ten sposób:

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

Ten projekt idealnie pasuje do TextFields, które korzystają z paradygmatu podnoszenia stanu value, onValueChange. W przypadku wpisywania tekstu takie podejście ma jednak nieprzewidywalne wady. Problemy z głęboką synchronizacją w tym podejściu zostały szczegółowo opisane w poście na blogu dotyczącym skutecznego zarządzania stanem komponentu TextField w Compose.

Problem polega na tym, że nowy projekt TextFieldState nie jest bezpośrednio zgodny ze stanem interfejsu StateFlow ViewModel. Zastąpienie username: Stringpassword: String znakami username: TextFieldStatepassword: TextFieldState może wydawać się dziwne, ponieważ TextFieldState jest z natury strukturą danych, którą można modyfikować.

Często zaleca się unikanie umieszczania zależności interfejsu w ViewModel. Chociaż jest to na ogół dobra praktyka, czasami może być źle interpretowana. Dotyczy to zwłaszcza zależności Compose, które są czysto danymi i nie zawierają żadnych elementów interfejsu, takich jak TextFieldState.

Klasy takie jak MutableState czy TextFieldState to proste kontenery stanu, które są obsługiwane przez system stanu migawki Compose. Nie różnią się one od zależności takich jak StateFlow czy RxJava. Dlatego zachęcamy do ponownej oceny sposobu stosowania w kodzie zasady „brak zależności interfejsu w ViewModel”. Przechowywanie odwołania do TextFieldStateViewModel nie jest samo w sobie złą praktyką.

Zalecamy wyodrębnienie wartości takich jak username lub passwordUiState i przechowywanie ich w osobnym odwołaniu w 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)
    }
}

  • Zastąp MutableStateFlow<UiState> kilkoma wartościami TextFieldState.
  • Przekaż te obiekty TextFieldState do funkcji TextFields w funkcji LoginForm composable.

Podejście zgodne

Takie zmiany architektury nie zawsze są łatwe. Możesz nie mieć możliwości wprowadzenia tych zmian lub czas potrzebny na ich wprowadzenie może przewyższać korzyści z używania nowych TextField. W takim przypadku możesz nadal używać pól tekstowych opartych na stanie, ale z niewielką zmianą.

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

  • Zachowaj te same zajęcia ViewModel i UiState.
  • Zamiast przenosić stan bezpośrednio do komponentu ViewModel i ustawiać go jako źródło informacji o TextFields, przekształć ViewModel w prosty kontener danych.
    • Aby to zrobić, obserwuj zmiany każdego TextFieldState.text, zbierając snapshotFlowLaunchedEffect.
  • Twój ViewModel będzie nadal zawierać najnowsze wartości z interfejsu, ale jego uiState: StateFlow<UiState> nie będzie wpływać na TextField.
  • Wszelkie inne mechanizmy utrwalania danych zaimplementowane w ViewModel mogą pozostać bez zmian.