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

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

  • แทนที่ลูป Callback ของค่าด้วย 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)
}

  • แทนที่ลูป Callback ของค่าด้วย 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)
    )
}

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

  • แทนที่ลูป Callback ของค่าด้วย rememberTextFieldState()
  • แทนที่ maxLines = 10 ด้วย lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10)
  • เปลี่ยนตรรกะการคำนวณ TextFieldValue ใหม่ด้วยการเรียก TextFieldState.edit
    • ระบบจะสร้าง TextFieldValue ใหม่โดยการแยกข้อความที่มีอยู่ตามการเลือกปัจจุบัน และแทรกการตกแต่ง Markdown ไว้ระหว่างนั้น
    • นอกจากนี้ ระบบจะปรับการเลือกตามดัชนีใหม่ของข้อความด้วย
    • 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 เป็นโครงสร้างข้อมูลที่เปลี่ยนแปลงได้โดยเนื้อแท้

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

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