Migracja do pól tekstowych zależnych od stanu

Na tej stronie znajdziesz przykłady migracji TextFieldokreślanych na podstawie wartościTextField na TextFieldokreślane na podstawie stanuTextField. 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, onValueChange i remember { 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 zmienną rememberTextFieldState().
  • Ponownie zaimplementuj 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 zmienną 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 zmienną 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 do wpisywania tekstu, 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 tym poście na blogu.

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 ogólnie 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 obiekty przechowujące stan, które są obsługiwane przez system stanu Snapshot w Compose. Nie różnią się one od zależności takich jak StateFlow czy RxJava. Dlatego zachęcamy Cię do ponownej oceny sposobu stosowania w kodzie zasady „brak zależności interfejsu w obiekcie ViewModel”. Przechowywanie odwołania do TextFieldStateViewModel nie jest samo w sobie złym rozwiązaniem.

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.