TextFieidStateの配置場所とViewModelでの扱いを考えてみる（Jetpack Compose）

## 概要

[Compose Foundation 1.7.0](https://developer.android.com/jetpack/androidx/releases/compose-foundation#1.7.0:~:text=BasicTextField%20using%20a%20TextFieldState%20is%20now%20stable%20and%20we%20advise%20all%20call%2Dsites%20to%20begin%20migration%20from%20BasicTextField(value%2C%20onValueChange)%20to%20BasicTextField(TextFieldState).)で既存のBasicTextFieldに代わり、TextFieldStateをとるBasicTextField(旧BasicTextField)が正式に採用されました。

新BasicTextFieldはIFごと刷新し、既存のBasicTextFieldで抱えていた様々な不具合を解消したといわれています。新BasicTextFieldを使う上でTextFieldStateの配置場所に悩んだので、この記事でコードを交えながら見ていきます。

※BasicTextFieldの使い方やTextFieldStateの使い方はこの記事では言及しないことにします

## 配置場所と扱いの検討

ViewModelを利用した構成とし、公式ドキュメントが示している通りViewModelにTextFieldを配置するのがよいか、UIにTextFieldStateを配置するのが良いか、比較してみます。

また、ユーザーの入力を検知することに加え、ViewModelからUIを更新することも踏まえた構成の検討も併せてコードを交えて行っていきます。

### 案1: ViewModelに直接置く

ViewModel内にtextFieldStateを置き、snapShotFlowでテキスト入力の検知を行います。[公式ドキュメント](https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/input/TextFieldState)に沿ったやり方で、一番シンプルな方法だと思います。


```kotlin
data class UiState(
    val checked: Boolean = false
)
```

```kotlin:ViewModel
class SampleViewModel : ViewModel() {
    private val validationRegex = """\w+""".toRegex()

    private val _uiState = MutableStateFlow(UiState())
    val uiState = _uiState.asStateFlow()

    val textFieldState = TextFieldState()
    val isTextValid by derivedStateOf {
        textFieldState.text.matches(validationRegex)
    }

    private fun onFetchText(val text: String) {
        // ViewModelから更新する場合
        textFieldState.edit {
            replace(0, text.length, text)
        }
        _uiState.update { it.copy(checked = true) }
    }

    init {
        // 入力の検知
        snapShotFlow { textFieldState.text }
            .collectLatest { /* ... */ }
    }
}
```

```kotlin:UI
@Composable
fun SampleScreen(viewModel: SampleViewModel) {
    val uiState = viewModel.uiState
    
    BasicTextField(
        state = viewModel.textFieldState,
        // ...
    )
}
```

#### メリット

- シンプルに書くことができる
- 公式ドキュメントに沿ったやり方である

#### デメリット

- TextFieldStateやsnapShotFlowをViewModelで持つことになる
- snapShotFlowを用いているので、単体テストと相性が悪い

### 案2: UIに配置し、ViewModelから更新する場合はFlowを介してUIに反映させる

TextFieldStateをUIに寄せることで、関心の分離を図ってみます。またAPIを介した処理などViewEventからテキストを更新する場合は、Flowを介してUIに伝搬させることで実現します。

```text
        -----> Flow Event  ----->
ViewModel                        UI
        <---- onChangeText <-----
```

```kotlin
class SampleViewModel : ViewModel() {
    private val validationRegex = """\w+""".toRegex()

    private val _viewEvent = Channel<String>()
    val viewEvent = _viewEvent.receiveAsFlow()

    private val _uiState = MutableStateFlow(UiState())
    val uiState = _uiState.receiveAsState()
    
    private val _isTextValid = uiState.map { it.text.matches(validationRegex) }
    
    private fun onFetchText(val fetchedText: String) {
        // ViewModelから更新する場合
        viewModelScope.launch {
            _viewEvent.send(fetchedText)
        }
        uiState.update {
            it.copy(checked = true)
        }
    }

    fun onChangeText(newText: String) {
        // 入力をハンドリング
    }
}
```

UIではviewEventを介して更新した場合もonChangeTextは呼ばれるため、`_text`はUIと同期されます。
また、viewModelのviewEventを受けたときにtextFieldStateを更新し、textFieldStateのtextが変化したら、viewModelのonChangeTextを呼び出してUIからViewModelに反映します。

```kotlin
@Composable
fun SampleScreen(viewModel: SampleViewModel) {
    val uiState = viewModel.uiState
    val textFieldState = rememberTextFieldState()

    // textFieldStateの更新
    LaunchedEffect(Unit) {
        viewModel.viewEvent.collect { text ->
            textFieldState.edit {
                replace(0, length, text)
            }
        }
    }
    
    // 入力の検知
    LaunchedEffect(Unit) {
        snapShotFlow { textFieldState.text }
            .collectLatest { viewModel.onChangeText(it) }
    }

    BasicTextField(
        state = textFieldState
    )
}
```

#### メリット

- UDFのような書き方ができる
- ある程度関心の分離ができている
- （おそらく）旧BasicTextField、TextField及びEditTextの場合にも流用できる

#### デメリット

- イベントを介してテキスト操作を行うため、直感的ではない
- MutableStateFlowの更新とイベント更新を別々に行う必要があり、UIの更新処理が統一的でない

### 案3: UIに配置し、ViewModelから更新する場合はStateFlowを介してUIに反映させる

案2でTextFieldStateをViewModelから切り離し関心の分離を図ってみましたが、他のUI要素とは別に管理してイベントで伝搬させる必要があり、使いにくさが残ります。

この案では入力中のテキストを含めたUIの状態をMutableStateFlowで管理し、MutableStateFlowの更新をすることでViewModelからUIに伝搬させることを提案します。

```text
        ----->   UI State  ----->
ViewModel                        UI
        <---- onChangeText <-----
```

ただし、入力テキストが変化したときに単純にMutableStateFlowを更新するとMutableStateFlowからデータが流れ、もう一度UIにデータが反映されることになります。これは二重更新を引き起こす可能性が高いので、回避するようにします。

```text
　　onChangeText        データ更新
UI -------> ViewModel -------> UI
```

つまり、入力テキストが変化したときにMutableStateStateからデータが流れないようにすればよいです。これを実現する簡単な方法の一つとして以下のようにすればよいと考えられます。

- textの状態をvarで定義する
- ViewModelからUIの状態を更新する場合は、MutableStateFlowを更新する
- UIからViewModelには、MutableStateFlowを更新せず直接textを書き換える

```kotlin
data class UiState(
    var text: String,
    val checked: Boolean,
)
```

```kotlin
class SampleViewModel : ViewModel() {
    private val validationRegex = """\w+""".toRegex()

    private val _viewEvent = Channel<String>()
    val viewEvent = _viewEvent.receiveAsFlow()

    val uiState = MutableStateFlow(
        UiState(
            text = "",
            checked = false,
        )
    )
    
    private val _isTextValid = uiState.map { it.text.matches(validationRegex) }
    
    private fun onFetchText(val fetchedText: String) {
        // ViewModelから更新する場合
        // 案2と異なり、MutableStateFlowの更新でUIに伝搬させる
        _uiState.update {
            it.copy(
                text = fetchedText,
                checked = true
            )
        }
    }

    fun onChangeText(newText: String) {
        _uiState.value.text = newText  // _uiState.update() でないことに注意
    }
}
```

UIでは案2と同様にViewModelから更新した場合も、onChangeTextが呼ばれます。
そして案2と異なり、uiStateのtextが更新されたらtextFieldStateを更新するようにします。

```kotlin
@Composable
fun SampleScreen(viewModel: SampleViewModel) {
    val uiState = viewModel.uiState
    val textFieldState = rememberTextFieldState()

    // textFieldStateの更新
    LaunchedEffect(Unit) {
        snapShotFlow { uiState.text }.collect { text ->
            textFieldState.edit {
                replace(0, length, text)
            }
        }
    }
    
    // 入力の検知
    LaunchedEffect(Unit) {
        snapShotFlow { textFieldState.text }
            .collectLatest { viewModel.onChangeText(it) }
    }

    BasicTextField(
        state = textFieldState
    )
}
```


### メリット

- イベントを使わず、よりUDFに沿った書き方ができる（案2のデメリットを解消）
- UIの更新を統一的に行える（案2のデメリットを解消）

### デメリット

- UiStateの変数としてvarで定義して管理することになる


## 結論

ユーザーの入力検知に加え、ViewModelから更新することも想定してTextFieldの配置場所とViewModelでの取り扱いを3案考えてみました。

公式ではViewModelにTextFieldStateを配置する方法が記載されていますが、最もUDFに沿った書き方ができ、かつ関心の分離も行える案3のUIに配置+MutableStateFlowで入力を管理する方法が有効であると感じました。

## おわりに

またまだTextFieldStateに関する情報も少ないので、より良い方法の検討も続けていけたらと思います。
また本記事が皆様の開発の助けになれば幸いです。

最後まで閲覧いただきありがとうございました。
些細なことから改善案までコメント歓迎しています！

## 参考サイト

- [UI layer (Android Developers > Design & Plan > App architecture)](https://developer.android.com/topic/architecture/ui-layer)
- [TextFieldState (Android Developers > Develop > API reference)](https://developer.android.com/reference/kotlin/androidx/compose/foundation/text/input/TextFieldState)
- [Kenji Abe, 2024/09/23, 「TextFieldStateの使い方」- Medium](https://star-zero.medium.com/textfieldstate%E3%81%AE%E4%BD%BF%E3%81%84%E6%96%B9-5ac430ddda88)