この記事で伝えたいこと
初心者向けの記事です。
ViewModelを使ったScreenを作成した際、Previewができず困ったので、どうやって解決したかをまとめておきたいと思います。
Jetpack ComposeでのPreviewについて
Jetpack Composeコンポーネントは、Android Studioでプレビューすることができます。
例えばこんな感じです。
@Composable
fun SampleScreen(text: String) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = text)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSampleScreen() {
SampleScreen("サンプル画面")
}
@Previewでアノテーションをつけたコンポーズを、プレビューウインドウに表示することができます。
引数に値を入れて表示することも可能なので、プレビューはつまるところ、UIテストのひとつだと考えていいかと思われます。
ViewModelを使ったプレビューを表示する時に発生するエラー
まず、こちらのコードを紹介します。
チャット画面です。主にViewModelから受け取ったメッセージの表示機能と、TextFieldで入力した文字の送信機能があります。
@Composable
fun ChatRoomScreen(
toHome: () -> Unit,
viewModel: ChatRoomViewModel = hiltViewModel()
) {
val allMessages by viewModel.messageList.collectAsState()
var text by remember { mutableStateOf("") }
val context = LocalContext.current
val scrollState = rememberLazyListState()
Box(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Column {
Row {
Button(onClick = { toHome() }) {
Text(text = "退室")
}
Spacer(modifier = Modifier.padding(8.dp))
}
}
Spacer(modifier = Modifier.padding(8.dp))
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
state = scrollState
) {
items(allMessages) { message ->
Text(
text = message,
modifier = Modifier
.background(
color = Color(0xFF9BFF9F),
shape = RoundedCornerShape(50)
)
.padding(8.dp)
)
}
}
}
Column(
modifier = Modifier
.padding(32.dp)
.align(Alignment.BottomStart),
) {
inputTextField(
text = text,
onValueChange = { newText -> text = newText },
onImeAction = {
viewModel.sendMessage(text, context)
text = ""
}
)
}
LaunchedEffect(allMessages) {
scrollState.scrollToItem(allMessages.size)
}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun inputTextField(
text: String,
onValueChange: (String) -> Unit,
onImeAction: () -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
BasicTextField(
value = text,
onValueChange = { newText ->
onValueChange(newText)
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = {
keyboardController?.hide()
onImeAction()
}),
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(color = LightGray, shape = RoundedCornerShape(50))
.padding(top = 8.dp, start = 16.dp)
)
}
こうやってPreviewを試みました。
@Preview(showbackground = true)
@Composable
fun PreviewChatRoomScreen() {
val navController = rememberNavController()
ChatApplication.chatDatabase = Room.databaseBuilder(
LocalContext.current, ChatDatabase::class.java, "chat-database"
).build()
CatterBoxTheme {
ChatRoomScreen(
toHome = { navController.navigate("home") },
viewModel = hiltViewModel()
)
}
}
すると、プレビューウインドウにエラーが表示されます
では、エラーログを見てみましょう
Failed to instantiate a ViewModel
This preview uses a ViewModel. ViewModels often trigger operations not supported by Compose Preview, such as database access, I/O operations, or network requests. You can read more about preview limitations in our external documentation.
はい、ChatGPTさん日本語に翻訳お願いします。
ViewModelのインスタンス化に失敗しました
このプレビューはViewModelを使用しています。ViewModelは、データベースアクセス、I/O操作、ネットワークリクエストなど、Composeプレビューではサポートされていない操作をよくトリガーします。プレビューの制約について詳しくは外部ドキュメンテーションをご覧いただけます。
要するに、ComposeのプレビューでViewModelを使用する時、サポート対象外になりがちみたいです。
解決策
ViewModelに依存しないコンポーザブルを作ります。
まずはChatRoomScreenでViewModelを使っている箇所を特定します。
@Composable
fun ChatRoomScreen(
// ...
) {
val allMessages by chatViewModel.messageList.collectAsState()
// ...
}
ここでは、collectAsStateでViewModelから値を収集し、allMessagesに値を格納しています。
@Composable
fun ChatRoomScreen(
// ...
) {
// ...
inputTextField(
text = text,
onValueChange = { newText -> text = newText },
onImeAction = {
viewModel.sendMessage(text, context)
text = ""
}
)
}
inputTextFieldの中のviewModel.sendMessageでは、viewModelを使って値を送信する処理を実行しています。
これらの処理を呼び出し先にお任せすることで、ViewModelに依存しないコンポーザブルを作ることが可能になります。
viewModelに依存したこれら処理の、呼び出し先をChatRoomScreen、呼び出し元をChatRoomContentとして、分割していきます。
@Composable
fun ChatRoomScreen(
toHome: () -> Unit,
viewModel: ChatRoomViewModel = hiltViewModel()
) {
val allMessages by viewModel.messageList.collectAsStateWithLifecycle()
var text by remember { mutableStateOf("") }
val context = LocalContext.current
ChatRoomContent(
toHome = toHome,
allMessages = allMessages,
text = text,
onValueChange = { newText ->
text = newText
},
onImeAction = {
viewModel.sendMessage(text, context)
text = ""
}
)
}
@Composable
fun ChatRoomContent(
toHome: () -> Unit,
allMessages: List<String>,
text: String = "",
onValueChange: (String) -> Unit,
onImeAction: () -> Unit
) {
val scrollState = rememberLazyListState()
Box(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Column {
Row {
Button(onClick = { toHome() }) {
Text(text = "退室")
}
Spacer(modifier = Modifier.padding(8.dp))
}
}
Spacer(modifier = Modifier.padding(8.dp))
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 56.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
state = scrollState
) {
items(allMessages) { message ->
Text(
text = message,
modifier = Modifier
.background(
color = Color(0xFF9BFF9F),
shape = RoundedCornerShape(50)
)
.padding(8.dp)
)
}
}
}
Column(
modifier = Modifier
.padding(32.dp)
.align(Alignment.BottomStart),
) {
inputTextField(
text = text,
onValueChange = { newText ->
onValueChange(newText)
},
onImeAction = {
onImeAction()
}
)
}
LaunchedEffect(allMessages) {
scrollState.scrollToItem(allMessages.size)
}
}
}
ChatRoomContentでは元のChatRoomScreenから、2箇所の修正を加えてあります。
【1】allMessageをStringのリストとして宣言する
@Composable
fun ChatRoomContent(
allMessages: List<String>,
// ...
) {
// ...
}
こうすることで、呼び出し先のChatRoomScreenでViewModelを使って収集した値の入れ物になります。ViewModelの依存性を消すことができました。
【2】TextFieldに関連する一連を、呼び出し元で処理する
@Composable
fun ChatRoomContent(
text: String = "",
onValueChange: (String) -> Unit,
onImeAction: () -> Unit
// ...
) {
// ...
inputTextField(
text = text,
onValueChange = { newText ->
onValueChange(newText)
},
onImeAction = {
onImeAction()
}
)
// ...
}
onValueChangeとonImeAction、textを引数にして、呼び出し元で処理できるようにしました。sendMessageもImeActionで実行できるようにしてあります。
キーボードの処理ごと変えるのは、テキストの状態を呼び出し元で管理しないと、状態変化を扱うことができないからです。
このような巻き取り方を、状態ホイスティングといいます。
参照:https://developer.android.com/jetpack/compose/state-hoisting?hl=ja
これでPreviewは動いてくれるようになります
allMessageにはプレビューで表示させたいメッセージを、textには入力欄で表示させたいメッセージを入れます。
@Preview(showBackground = true)
@Composable
fun PreviewChatRoomScreen() {
val navController = rememberNavController()
ChatApplication.chatDatabase = Room.databaseBuilder(
LocalContext.current, ChatDatabase::class.java, "chat-database"
).build()
CatterBoxTheme {
ChatRoomContent(
toHome = { navController.navigate("home") },
allMessages = listOf("test1", "test2", "test3"),
text = "入力中",
onValueChange = {},
onImeAction = {}
)
}
}