Bermigrasi ke kolom teks berbasis status

Halaman ini memberikan contoh cara memigrasikan TextField berbasis nilai ke TextField berbasis status. Lihat halaman Mengonfigurasi kolom teks untuk mengetahui informasi tentang perbedaan antara TextField berbasis nilai dan status.

Penggunaan dasar

Berbasis nilai

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

Berdasarkan status

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

  • Ganti value, onValueChange, dan remember { mutableStateOf("") } dengan rememberTextFieldState().
  • Mengganti singleLine = true dengan lineLimits = TextFieldLineLimits.SingleLine.

Memfilter melalui onValueChange

Berbasis nilai

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

Berdasarkan status

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

  • Ganti loop callback nilai dengan rememberTextFieldState().
  • Terapkan kembali logika pemfilteran di onValueChange menggunakan InputTransformation.
  • Gunakan TextFieldBuffer dari cakupan penerima InputTransformation untuk memperbarui state.
    • InputTransformation dipanggil tepat setelah input pengguna terdeteksi.
    • Perubahan yang diusulkan oleh InputTransformation melalui TextFieldBuffer akan segera diterapkan, sehingga menghindari masalah sinkronisasi antara keyboard software dan TextField.

Pemformat kartu kredit TextField

Berbasis nilai

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

Berdasarkan status

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

  • Ganti pemfilteran di onValueChange dengan InputTransformation untuk menetapkan panjang maksimum input.
  • Ganti VisualTransformation dengan OutputTransformation untuk menambahkan tanda hubung.
    • Dengan VisualTransformation, Anda bertanggung jawab untuk membuat teks baru dengan tanda hubung dan menghitung cara indeks dipetakan antara teks visual dan status pendukung.
    • OutputTransformation menangani pemetaan offset secara otomatis. Anda hanya perlu menambahkan tanda hubung di tempat yang benar menggunakan TextFieldBuffer dari cakupan penerima OutputTransformation.transformOutput.

Memperbarui status (sederhana)

Berbasis nilai

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

Berdasarkan status

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

  • Ganti loop callback nilai dengan rememberTextFieldState().
  • Ubah penetapan nilai dengan TextFieldState.setTextAndPlaceCursorAtEnd.

Memperbarui status (kompleks)

Berbasis nilai

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

Berdasarkan status

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

Dalam kasus penggunaan ini, tombol menambahkan dekorasi Markdown untuk membuat teks tebal di sekitar kursor atau pilihan saat ini. Hal ini juga mempertahankan posisi pilihan setelah perubahan.

  • Ganti loop callback nilai dengan rememberTextFieldState().
  • Mengganti maxLines = 10 dengan lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10).
  • Ubah logika penghitungan TextFieldValue baru dengan panggilan TextFieldState.edit.
    • TextFieldValue baru dibuat dengan menggabungkan teks yang ada berdasarkan pilihan saat ini, dan menyisipkan dekorasi Markdown di antaranya.
    • Selain itu, pilihan disesuaikan menurut indeks baru teks.
    • TextFieldState.edit memiliki cara yang lebih alami untuk mengedit status saat ini dengan penggunaan TextFieldBuffer.
    • Pilihan ini secara eksplisit menentukan tempat untuk menyisipkan dekorasi.
    • Kemudian, sesuaikan pilihan, mirip dengan pendekatan onValueChange.

Arsitektur ViewModel StateFlow

Banyak aplikasi mengikuti Pedoman pengembangan aplikasi modern, yang mendorong penggunaan StateFlow untuk menentukan status UI layar atau komponen melalui satu class imutable yang membawa semua informasi.

Dalam jenis aplikasi ini, formulir seperti layar Login dengan input teks biasanya didesain sebagai berikut:

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

Desain ini sangat cocok dengan TextFields yang menggunakan paradigma pengangkatan status value, onValueChange. Namun, ada kekurangan yang tidak dapat diprediksi dari pendekatan ini dalam hal input teks. Masalah sinkronisasi mendalam dengan pendekatan ini dijelaskan secara mendetail dalam postingan blog Pengelolaan status yang efektif untuk TextField di Compose.

Masalahnya adalah desain TextFieldState baru tidak kompatibel secara langsung dengan status UI ViewModel yang didukung StateFlow. Mungkin terlihat aneh untuk mengganti username: String dan password: String dengan username: TextFieldState dan password: TextFieldState, karena TextFieldState adalah struktur data yang pada dasarnya dapat berubah.

Rekomendasi umum adalah menghindari penempatan dependensi UI ke dalam ViewModel. Meskipun umumnya merupakan praktik yang baik, terkadang hal ini dapat disalahartikan. Hal ini terutama berlaku untuk dependensi Compose yang murni merupakan struktur data dan tidak membawa elemen UI apa pun, seperti TextFieldState.

Class seperti MutableState atau TextFieldState adalah holder status sederhana yang didukung oleh sistem status Snapshot Compose. Tidak ada bedanya dengan dependensi seperti StateFlow atau RxJava. Oleh karena itu,sebaiknya Anda mengevaluasi kembali cara menerapkan prinsip "tidak ada dependensi UI di ViewModel" dalam kode Anda. Mempertahankan referensi ke TextFieldState dalam ViewModel Anda bukanlah praktik yang buruk.

Sebaiknya ekstrak nilai seperti username atau password dari UiState, dan simpan referensi terpisah untuk nilai tersebut di 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)
    }
}

  • Ganti MutableStateFlow<UiState> dengan beberapa nilai TextFieldState.
  • Teruskan objek TextFieldState tersebut ke TextFields di composable LoginForm.

Pendekatan yang sesuai

Jenis perubahan arsitektur ini tidak selalu mudah. Anda mungkin tidak memiliki kebebasan untuk melakukan perubahan ini, atau investasi waktu yang diperlukan mungkin lebih besar daripada manfaat penggunaan TextFields baru. Dalam hal ini, Anda tetap dapat menggunakan kolom teks berbasis status dengan sedikit penyesuaian.

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

  • Pertahankan kelas ViewModel dan UiState Anda tetap sama.
  • Daripada mengangkat status langsung ke ViewModel dan menjadikannya sumber kebenaran untuk TextFields, ubah ViewModel menjadi penampung data sederhana.
    • Untuk melakukannya, amati perubahan pada setiap TextFieldState.text dengan mengumpulkan snapshotFlow dalam LaunchedEffect.
  • ViewModel Anda akan tetap memiliki nilai terbaru dari UI, tetapi uiState: StateFlow<UiState>-nya tidak akan mendorong TextField.
  • Logika persistensi lain yang diterapkan di ViewModel Anda dapat tetap sama.