Migracja do pól tekstowych zależnych od stanu

Na tej stronie znajdziesz przykłady migracji TextFieldów opartych na wartościach do TextFieldów opartych na stanach. Informacje o różnicach między elementami 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,
    )
}

Na podstawie stanu

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

  • Zastąp value, onValueChange i remember { mutableStateOf("") wartością 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' }
        }
    )
}

Na podstawie 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().
  • Ponownie zaimplementuj logikę filtrowania w onValueChange za pomocą InputTransformation.
  • Użyj TextFieldBuffer z zakresu odbiornika InputTransformation, aby zaktualizować state.
    • Funkcja 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ą ekranową 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
                    }
                }
            )
        }
    )
}

Na podstawie 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 pliku onValueChange elementem InputTransformation, aby ustawić maksymalną długość danych wejściowych.
  • Zastąp VisualTransformation ciągiem OutputTransformation, aby dodać myślniki.
    • W przypadku VisualTransformation odpowiadasz zarówno za utworzenie nowego tekstu z kreskami, jak i za obliczenie sposobu mapowania indeksów między tekstem wizualnym a stanem bazowym.
    • OutputTransformation automatycznie zajmuje się mapowaniem przesunięcia. Wystarczy dodać kreski w odpowiednich miejscach, używając 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 }
    )
}

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

Na podstawie 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 znaczniki Markdown, aby pogrubić tekst wokół kursora lub bieżącego zaznaczenia. Po wprowadzeniu zmian zachowuje też pozycję zaznaczenia.

  • Zastąp pętlę wywołania zwrotnego wartością rememberTextFieldState().
  • Zastąp maxLines = 10 tekstem lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10).
  • Zmień logikę obliczania nowego elementu TextFieldValue za pomocą wywołania TextFieldState.edit.
    • Nowy element TextFieldValue jest generowany przez podzielenie 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.
    • Zaznaczenie jednoznacznie określa, gdzie należy wstawić dekoracje.
    • Następnie dostosuj zaznaczenie, podobnie jak w przypadku onValueChange.

Architektura ViewModel StateFlow

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

W tego typu aplikacjach formularz taki jak 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 używają paradygmatu przenoszenia stanu value, onValueChange. Jednak w przypadku wprowadzania tekstu to podejście ma nieprzewidywalne wady. Problemy z głęboką synchronizacją związane z tym podejściem zostały szczegółowo wyjaśnione w poście na blogu Skuteczne zarządzanie stanem TextField w Compose.

Problem polega na tym, że nowy projekt TextFieldState nie jest bezpośrednio zgodny ze stanem interfejsu ViewModel opartym na StateFlow. Zastąpienie username: String i password: String przez username: TextFieldState i password: TextFieldState może wydawać się dziwne, ponieważ TextFieldState jest z natury zmienną strukturą danych.

Często zaleca się unikanie umieszczania zależności interfejsu w ViewModel. Chociaż jest to ogólnie dobra praktyka, czasami może być błędnie interpretowana. Dotyczy to zwłaszcza zależności Compose, które są wyłącznie strukturami danych i nie zawierają żadnych elementów interfejsu, takich jak TextFieldState.

Klasy takie jak MutableState czy TextFieldState to proste zmienne stanu, które są obsługiwane przez system stanu Snapshot w Compose. Nie różnią się 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 TextFieldState w ViewModel nie jest złą praktyką.

Zalecamy wyodrębnienie wartości takich jak username lub password z UiState i zachowanie dla nich osobnego odwołania 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 TextFields w funkcji kompozycyjnej LoginForm.

Podejście zgodne

Tego typu zmiany architektoniczne nie zawsze są łatwe. Możesz nie mieć możliwości wprowadzenia tych zmian lub czas, który musisz na to poświęcić, może przewyższać korzyści płynące z używania nowych TextField. W takim przypadku możesz nadal używać pól tekstowych opartych na stanie, ale z niewielką modyfikacją.

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(passwordState) {
            snapshotFlow { passwordState.text.toString() }.collectLatest {
                loginViewModel.updatePassword(it)
            }
        }
        SecureTextField(passwordState)
    }
}

  • Nie zmieniaj klas ViewModel i UiState.
  • Zamiast przenosić stan bezpośrednio do ViewModel i robić z niego źródło prawdy dla TextFields, przekształć ViewModel w prosty obiekt przechowujący dane.
    • Aby to zrobić, obserwuj zmiany w każdym TextFieldState.text, zbierając snapshotFlow w LaunchedEffect.
  • W elemencie ViewModel nadal będą widoczne najnowsze wartości z interfejsu, ale jego uiState: StateFlow<UiState> nie będzie wpływać na TextField.
  • Wszelka inna logika trwałości zaimplementowana w ViewModel może pozostać bez zmian.