状態ベースのテキスト フィールドに移行する

このページでは、値ベースの 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() に置き換えます。
  • InputTransformation を使用して、onValueChange のフィルタリング ロジックを再実装します。
  • InputTransformation のレシーバー スコープから TextFieldBuffer を使用して、state を更新します。
    • InputTransformation は、ユーザー入力が検出された直後に呼び出されます。
    • TextFieldBuffer を介して InputTransformation によって提案された変更はすぐに適用され、ソフトウェア キーボードと 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() に置き換えます。
  • maxLines = 10 を lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10) に置き換えます。
  • TextFieldState.edit 呼び出しで新しい TextFieldValue を計算するロジックを変更します。
    • 現在の選択に基づいて既存のテキストを分割し、その間にマークダウン装飾を挿入することで、新しい 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 状態と直接互換性がないことです。TextFieldState は本質的に可変のデータ構造であるため、username: String と password: String を username: TextFieldState と password: TextFieldState に置き換えるのは不自然に思えるかもしれません。

一般的な推奨事項は、UI 依存関係を ViewModel に配置しないことです。これは一般的には良い方法ですが、誤解を招くこともあります。これは、TextFieldState のように、純粋にデータ構造であり、UI 要素を伴わない Compose 依存関係に特に当てはまります。

MutableState や TextFieldState などのクラスは、Compose のスナップショット状態システムを基盤とするシンプルな状態ホルダーです。StateFlow や RxJava などの依存関係と違いはありません。そのため、コードで「ViewModel に UI 依存関係がない」という原則をどのように適用するかを再評価することをおすすめします。ViewModel 内に TextFieldState への参照を保持することは、本質的に悪い方法ではありません。

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 値に置き換えます。
  • これらの 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(passwordState) {
            snapshotFlow { passwordState.text.toString() }.collectLatest {
                loginViewModel.updatePassword(it)
            }
        }
        SecureTextField(passwordState)
    }
}

  • ViewModel クラスと UiState クラスは同じままにします。
  • 状態を ViewModel に直接ホイスティングして TextFields の信頼できる情報源にするのではなく、ViewModel をシンプルなデータホルダーにします。
    • これを行うには、LaunchedEffect で snapshotFlow を収集して、各 TextFieldState.text の変更を監視します。
  • ViewModel には引き続き UI の最新の値が保持されますが、uiState: StateFlow<UiState> が TextField を駆動することはありません。
  • ViewModel に実装されている他の永続性ロジックは、そのままにしておくことができます。