ย้ายข้อมูลไปยังช่องข้อความตามสถานะ

หน้านี้แสดงตัวอย่างวิธีย้ายข้อมูล 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)
    )
}

ในตัวอย่างนี้ ปุ่มจะเพิ่มการตกแต่งมาร์กดาวน์เพื่อทำให้ข้อความเป็นตัวหนารอบๆ เคอร์เซอร์หรือส่วนที่เลือกอยู่ รวมถึงรักษาตำแหน่งการเลือกไว้หลังจากการเปลี่ยนแปลงด้วย

  • แทนที่ลูปการเรียกกลับค่าด้วย rememberTextFieldState()
  • แทนที่ maxLines = 10 ด้วย lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10)
  • เปลี่ยนตรรกะการคํานวณ TextFieldValue ใหม่ด้วย CallType TextFieldState.edit
    • ระบบจะสร้าง TextFieldValue ใหม่โดยการต่อข้อความที่มีอยู่ตามการเลือกปัจจุบัน และแทรกการตกแต่ง Markdown ไว้ตรงกลาง
    • นอกจากนี้ ระบบจะปรับการเลือกตามดัชนีใหม่ของข้อความด้วย
    • TextFieldState.edit มีวิธีแก้ไขสถานะปัจจุบันที่เป็นธรรมชาติมากขึ้นเมื่อใช้ TextFieldBuffer
    • การเลือกจะกำหนดตำแหน่งที่จะแทรกการตกแต่งอย่างชัดเจน
    • จากนั้นปรับการเลือกให้คล้ายกับแนวทาง onValueChange

สถาปัตยกรรม StateFlow ของ ViewModel

แอปพลิเคชันจำนวนมากเป็นไปตามหลักเกณฑ์การพัฒนาแอปสมัยใหม่ ซึ่งส่งเสริมให้ใช้ StateFlow เพื่อกำหนดสถานะ UI ของหน้าจอหรือคอมโพเนนต์ผ่านคลาสแบบคงที่รายการเดียวที่มีข้อมูลทั้งหมด

ในแอปพลิเคชันประเภทเหล่านี้ โดยทั่วไปแล้วแบบฟอร์มอย่างหน้าจอการเข้าสู่ระบบที่มีการป้อนข้อความจะออกแบบดังนี้

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 ใหม่ใช้ร่วมกับสถานะ UI ของ ViewModel ที่ StateFlow รองรับไม่ได้โดยตรง การแทนที่ username: String และ password: String ด้วย username: TextFieldState และ password: TextFieldState อาจดูแปลกๆ เนื่องจาก TextFieldState เป็นโครงสร้างข้อมูลที่เปลี่ยนแปลงได้อยู่แล้ว

คําแนะนําทั่วไปคือหลีกเลี่ยงการใส่ทรัพยากร ViewModel ไว้ใน ViewModel แม้ว่าโดยทั่วไปแล้วแนวทางนี้จะเป็นสิ่งที่ควรทำ แต่บางครั้งก็อาจมีการตีความที่ผิด โดยเฉพาะอย่างยิ่งสำหรับ Compose Dependencies ที่เป็นโครงสร้างข้อมูลล้วนๆ และไม่มีองค์ประกอบ UI เช่น TextFieldState

คลาสอย่าง MutableState หรือ TextFieldState เป็นตัวเก็บสถานะแบบง่ายที่รองรับโดยระบบสถานะภาพรวมของ Compose ซึ่งไม่แตกต่างจากข้อกำหนดเบื้องต้น เช่น StateFlow หรือ RxJava เราจึงขอแนะนำให้คุณประเมินอีกครั้งว่าคุณใช้หลักการ "ไม่มีทรัพยากร UI ใน 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 2 ค่า
  • ส่งออบเจ็กต์ TextFieldState เหล่านั้นไปยัง TextFields ใน LoginForm composable

แนวทางการปฏิบัติตามข้อกำหนด

การเปลี่ยนแปลงสถาปัตยกรรมประเภทนี้ไม่ใช่เรื่องง่ายเสมอไป คุณอาจไม่มีอิสระที่จะทำการเปลี่ยนแปลงเหล่านี้ หรือการลงทุนด้านเวลาอาจไม่คุ้มค่ากับประโยชน์ที่ได้รับจากการใช้ 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(usernameState) {
            snapshotFlow { usernameState.text.toString() }.collectLatest {
                loginViewModel.updatePassword(it)
            }
        }
        SecureTextField(passwordState)
    }
}

  • เปลี่ยนชื่อชั้นเรียน ViewModel และ UiState ให้เป็นชื่อเดิม
  • แทนที่จะส่งสถานะไปยัง ViewModel โดยตรงและทำให้ ViewModel เป็นแหล่งข้อมูลที่เป็นความจริงสำหรับ TextFields ให้เปลี่ยน ViewModel เป็นผู้เก็บข้อมูลธรรมดา
    • โดยสังเกตการเปลี่ยนแปลงของ TextFieldState.text แต่ละรายการด้วยการรวบรวม snapshotFlow ใน LaunchedEffect
  • ViewModel จะยังคงมีค่าล่าสุดจาก UI แต่ uiState: StateFlow<UiState> ของ ViewModel จะไม่เป็นตัวขับเคลื่อน TextField
  • ตรรกะการคงข้อมูลอื่นๆ ที่ใช้กับ ViewModel จะยังคงเหมือนเดิม