Переход на текстовые поля на основе состояний

На этой странице приведены примеры того, как можно перенести 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, onValueChange и remember { mutableStateOf("") на rememberTextFieldState().
  • Замените singleLine = true на lineLimits = 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().
  • Повторно реализуйте логику фильтрации в onValueChange с помощью InputTransformation.
  • Используйте TextFieldBuffer из области действия получателя InputTransformation, чтобы обновить 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, чтобы задать максимальную длину ввода.
  • Замените VisualTransformation на OutputTransformation, чтобы добавить тире.
    • При использовании VisualTransformation вы должны не только создать новый текст с дефисами, но и рассчитать, как индексы сопоставляются между визуальным текстом и состоянием поддержки.
    • OutputTransformation автоматически сопоставляет смещения. Вам нужно только добавить дефисы в правильные места, используя TextFieldBuffer из области видимости получателя OutputTransformation.transformOutput.

Обновление статуса (простой вариант)

На основе ценности

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

В этом случае кнопка добавляет элементы разметки Markdown, чтобы сделать текст вокруг курсора или текущего выделения полужирным. Он также сохраняет позицию выбора после изменений.

  • Замените цикл обратного вызова значения на rememberTextFieldState().
  • Замените maxLines = 10 на lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10).
  • Измените логику вычисления нового значения TextFieldValue с помощью вызова TextFieldState.edit.
    • Новый объект TextFieldValue создается путем разделения существующего текста на основе текущего выделения и вставки между ними элементов разметки Markdown.
    • Выделение также корректируется в соответствии с новыми индексами текста.
    • TextFieldState.edit предлагает более естественный способ редактирования текущего состояния с помощью TextFieldBuffer.
    • Выделение явно определяет, куда вставлять декорации.
    • Затем скорректируйте выделение, как в подходе onValueChange.

Архитектура ViewModel StateFlow

Многие приложения следуют рекомендациям по современной разработке приложений, которые поощряют использование StateFlow для определения состояния пользовательского интерфейса экрана или компонента через один неизменяемый класс, содержащий всю информацию.

В таких приложениях форма, например экран входа с текстовым вводом, обычно разрабатывается следующим образом:

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

Такой дизайн идеально подходит для TextFields, в которых используется парадигма передачи состояния value, onValueChange. Однако у этого подхода есть непредсказуемые недостатки, когда дело доходит до ввода текста. Проблемы с глубокой синхронизацией, возникающие при таком подходе, подробно описаны в записи блога Эффективное управление состоянием для TextField в Compose.

Проблема в том, что новый дизайн TextFieldState несовместим напрямую с состоянием пользовательского интерфейса ViewModel, поддерживаемым StateFlow. Замена username: String и password: String на username: TextFieldState и password: TextFieldState может показаться странной, поскольку TextFieldState по своей природе является изменяемой структурой данных.

Рекомендуется избегать размещения зависимостей пользовательского интерфейса в ViewModel. Хотя в целом это хорошая практика, иногда она может быть неправильно истолкована. Это особенно актуально для зависимостей Compose, которые являются чисто структурами данных и не несут в себе никаких элементов пользовательского интерфейса, таких как TextFieldState.

Классы, такие как MutableState или TextFieldState, представляют собой простые хранилища состояния, поддерживаемые системой состояния Snapshot в Compose. Они ничем не отличаются от зависимостей, таких как StateFlow или RxJava. Поэтому мы рекомендуем вам пересмотреть то,как вы применяете принцип "никаких зависимостей от пользовательского интерфейса в ViewModel" в своем коде. Сохранение ссылки на TextFieldState в ViewModel само по себе не является плохой практикой.

Рекомендуем извлечь значения username или password из UiState и сохранить их в 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 в TextFields в composable-функции LoginForm.

Подход, соответствующий требованиям

Такие изменения в архитектуре не всегда легко реализовать. У вас может не быть возможности внести эти изменения, или затраты времени могут перевесить преимущества использования новых 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(passwordState) {
            snapshotFlow { passwordState.text.toString() }.collectLatest {
                loginViewModel.updatePassword(it)
            }
        }
        SecureTextField(passwordState)
    }
}

  • Не меняйте классы ViewModel и UiState.
  • Вместо того чтобы поднимать состояние непосредственно в ViewModel и делать его источником истины для TextFields, превратите ViewModel в простой держатель данных.
    • Для этого наблюдайте за изменениями каждого TextFieldState.text, собирая snapshotFlow в LaunchedEffect.
  • В ViewModel по-прежнему будут последние значения из интерфейса, но uiState: StateFlow<UiState> не будет влиять на TextField.
  • Другие логические элементы, реализованные в ViewModel, можно оставить без изменений.