概要
Compose Foundation 1.7.0で既存のBasicTextFieldに代わり、TextFieldStateをとるBasicTextField(旧BasicTextField)が正式に採用されました。
新BasicTextFieldはIFごと刷新し、既存のBasicTextFieldで抱えていた様々な不具合を解消したといわれています。新BasicTextFieldを使う上でTextFieldStateの配置場所に悩んだので、この記事でコードを交えながら見ていきます。
※BasicTextFieldの使い方やTextFieldStateの使い方はこの記事では言及しないことにします
配置場所と扱いの検討
ViewModelを利用した構成とし、公式ドキュメントが示している通りViewModelにTextFieldを配置するのがよいか、UIにTextFieldStateを配置するのが良いか、比較してみます。
また、ユーザーの入力を検知することに加え、ViewModelからUIを更新することも踏まえた構成の検討も併せてコードを交えて行っていきます。
案1: ViewModelに直接置く
ViewModel内にtextFieldStateを置き、snapShotFlowでテキスト入力の検知を行います。公式ドキュメントに沿ったやり方で、一番シンプルな方法だと思います。
data class UiState(
val checked: Boolean = false
)
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 { /* ... */ }
}
}
@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に伝搬させることで実現します。
-----> Flow Event ----->
ViewModel UI
<---- onChangeText <-----
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に反映します。
@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に伝搬させることを提案します。
-----> UI State ----->
ViewModel UI
<---- onChangeText <-----
ただし、入力テキストが変化したときに単純にMutableStateFlowを更新するとMutableStateFlowからデータが流れ、もう一度UIにデータが反映されることになります。これは二重更新を引き起こす可能性が高いので、回避するようにします。
onChangeText データ更新
UI -------> ViewModel -------> UI
つまり、入力テキストが変化したときにMutableStateStateからデータが流れないようにすればよいです。これを実現する簡単な方法の一つとして以下のようにすればよいと考えられます。
- textの状態をvarで定義する
- ViewModelからUIの状態を更新する場合は、MutableStateFlowを更新する
- UIからViewModelには、MutableStateFlowを更新せず直接textを書き換える
data class UiState(
var text: String,
val checked: Boolean,
)
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を更新するようにします。
@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に関する情報も少ないので、より良い方法の検討も続けていけたらと思います。
また本記事が皆様の開発の助けになれば幸いです。
最後まで閲覧いただきありがとうございました。
些細なことから改善案までコメント歓迎しています!