遷移至以狀態為準的文字欄位

本頁面提供範例,說明如何將以值為準的 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, onValueChangeremember { mutableStateOf("") } 替換為 rememberTextFieldState()
  • lineLimits = TextFieldLineLimits.SingleLine 取代 singleLine = true

透過 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()
  • 使用 InputTransformation 重新實作 onValueChange 中的篩選邏輯。
  • 使用 InputTransformation 接收器範圍中的 TextFieldBuffer 更新 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 會自動處理偏移對應。您只需要使用 OutputTransformation.transformOutput 接收器範圍中的 TextFieldBuffer,在正確位置新增破折號。

更新狀態 (簡單)

以價值為準

@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()
  • lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10) 取代 maxLines = 10
  • 變更使用 TextFieldState.edit 呼叫來計算新 TextFieldValue 的邏輯。
    • 系統會根據目前的選取項目拼接現有文字,並在其中插入 Markdown 修飾符,藉此產生新的 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()
        )
    }
}

這項設計與使用 value, onValueChange 狀態提升模式的 TextFields 完美搭配。不過,在文字輸入方面,這種做法會有難以預測的缺點。在 Compose 中有效管理 TextField 的狀態網誌文章中,詳細說明瞭這種做法所產生的深層同步問題。

問題是,新 TextFieldState 設計無法直接與 StateFlow 支援的 ViewModel UI 狀態相容。以 username: TextFieldStatepassword: TextFieldState 取代 username: Stringpassword: String 可能會看起來很奇怪,因為 TextFieldState 本身就是可變動的資料結構。

一般建議是避免將 UI 依附元件放入 ViewModel。雖然這通常是良好的做法,但有時可能會造成誤解。這對於 Compose 依附元件特別適用,因為這些依附元件是純粹的資料結構,且不含任何 UI 元素,例如 TextFieldState

MutableStateTextFieldState 等類別是簡單的狀態容器,由 Compose 的 Snapshot 狀態系統支援。這些與 StateFlowRxJava 等依附元件沒有差異。因此,建議您重新評估如何在程式碼中套用「無 ViewModel 中的 UI 依附元件」原則。在 ViewModel 中保留 TextFieldState 的參照並非不當做法。

建議您從 UiState 中擷取 usernamepassword 等值,並在 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 物件傳遞至 LoginForm 可組合函式中的 TextFields

符合性方法

這類架構變更不一定容易實施。您可能無法自由進行這些變更,或者花費的時間可能超過使用新 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)
    }
}

  • 請保持 ViewModelUiState 類別一致。
  • 請勿將狀態直接提升至 ViewModel,並將其設為 TextFields 的真實資訊來源,而是將 ViewModel 轉換為簡單的資料容器。
    • 如要這麼做,請在 LaunchedEffect 中收集 snapshotFlow,觀察每個 TextFieldState.text 的變更。
  • ViewModel 仍會取得 UI 的最新值,但其 uiState: StateFlow<UiState> 不會驅動 TextField
  • ViewModel 中實作的任何其他持久性邏輯都可以保持不變。