1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ViewModelを使うとプレビューができないなんて時に

Posted at

この記事で伝えたいこと

初心者向けの記事です。
ViewModelを使ったScreenを作成した際、Previewができず困ったので、どうやって解決したかをまとめておきたいと思います。

Jetpack ComposeでのPreviewについて

Jetpack Composeコンポーネントは、Android Studioでプレビューすることができます。
例えばこんな感じです。

SampleScreen
@Composable
fun SampleScreen(text: String) {
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Text(text = text)
    }
}

@Preview(showBackground = true)
@Composable
fun PreviewSampleScreen() {
    SampleScreen("サンプル画面")
}

スクリーンショット 2023-10-04 21.20.13.png

@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()
        )
    }
}

すると、プレビューウインドウにエラーが表示されます
スクリーンショット 2023-10-04 22.35.03.png
では、エラーログを見てみましょう

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を使っている箇所を特定します。

allMessages
@Composable
fun ChatRoomScreen(
    // ...
) {
    val allMessages by chatViewModel.messageList.collectAsState()
    // ...

}

ここでは、collectAsStateでViewModelから値を収集し、allMessagesに値を格納しています。

sendMessage
@Composable
fun ChatRoomScreen(
    // ...
) {
    // ...
            inputTextField(
                text = text,
                onValueChange = { newText -> text = newText },
                onImeAction = {
                    viewModel.sendMessage(text, context)
                    text = ""
                }
            )

}

inputTextFieldの中のviewModel.sendMessageでは、viewModelを使って値を送信する処理を実行しています。

これらの処理を呼び出し先にお任せすることで、ViewModelに依存しないコンポーザブルを作ることが可能になります。

viewModelに依存したこれら処理の、呼び出し先をChatRoomScreen、呼び出し元をChatRoomContentとして、分割していきます。

ChatRoomScreen
@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 = ""
        }
    )
}
ChatRoomContent
@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のリストとして宣言する

ChatRoomContent
@Composable
fun ChatRoomContent(
    allMessages: List<String>,
// ...
) {
// ...
}

こうすることで、呼び出し先のChatRoomScreenでViewModelを使って収集した値の入れ物になります。ViewModelの依存性を消すことができました。

【2】TextFieldに関連する一連を、呼び出し元で処理する

ChatRoomContent
@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には入力欄で表示させたいメッセージを入れます。

PreviewScreenContent
@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 = {}
        )
    }
}

スクリーンショット 2023-10-09 19.52.00.png

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?