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

หน้านี้แสดงตัวอย่างวิธีเปลี่ยน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 ใหม่ด้วยการเรียก TextFieldState.edit
    • ระบบจะสร้างTextFieldValueใหม่โดยการตัดต่อข้อความที่มีอยู่ตาม ข้อความที่เลือกในปัจจุบัน และแทรกการตกแต่งมาร์กดาวน์ไว้ตรงกลาง
    • นอกจากนี้ ระบบจะปรับการเลือกตามดัชนีใหม่ของข้อความด้วย
    • TextFieldState.edit มีวิธีแก้ไขสถานะปัจจุบันที่เป็นธรรมชาติมากขึ้น ด้วยการใช้ TextFieldBuffer
    • การเลือกจะกำหนดตำแหน่งที่จะแทรกการตกแต่งอย่างชัดเจน
    • จากนั้นปรับการเลือกในลักษณะเดียวกับonValueChange

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

แอปพลิเคชันจำนวนมากปฏิบัติตามหลักเกณฑ์การพัฒนาแอปสมัยใหม่ ซึ่ง ส่งเสริมการใช้ 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 เป็นโครงสร้างข้อมูลที่เปลี่ยนแปลงได้โดยธรรมชาติ

คำแนะนำที่พบบ่อยคือหลีกเลี่ยงการวางการอ้างอิง UI ลงใน ViewModel แม้ว่าโดยทั่วไปแล้วนี่จะเป็นแนวทางปฏิบัติที่ดี แต่บางครั้งก็อาจตีความผิดได้ โดยเฉพาะอย่างยิ่งสำหรับทรัพยากร Dependency ของ Compose ที่เป็นโครงสร้างข้อมูลอย่างเดียวและไม่มีองค์ประกอบ 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 ที่ประกอบได้

แนวทางที่สอดคล้องกัน

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