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

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

## Jetpack ComposeでのPreviewについて
Jetpack Composeコンポーネントは、Android Studioでプレビューすることができます。
例えばこんな感じです。
```Kotlin: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2524029/6d16596a-ff60-22fb-b053-681649bfff1f.png)

@Previewでアノテーションをつけたコンポーズを、プレビューウインドウに表示することができます。
引数に値を入れて表示することも可能なので、プレビューはつまるところ、UIテストのひとつだと考えていいかと思われます。

## ViewModelを使ったプレビューを表示する時に発生するエラー
まず、こちらのコードを紹介します。
チャット画面です。主にViewModelから受け取ったメッセージの表示機能と、TextFieldで入力した文字の送信機能があります。
```Kotlin
@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を試みました。

```Kotlin
@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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2524029/5b987618-4b77-cd85-b06f-612fc70684f3.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を使っている箇所を特定します。

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

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

```Kotlin: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として、分割していきます。

```Kotlin: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 = ""
        }
    )
}
```

```Kotlin: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のリストとして宣言する
```Kotlin:ChatRoomContent
@Composable
fun ChatRoomContent(
    allMessages: List<String>,
// ...
) {
// ...
}
```
こうすることで、呼び出し先のChatRoomScreenでViewModelを使って収集した値の入れ物になります。ViewModelの依存性を消すことができました。

### 【2】TextFieldに関連する一連を、呼び出し元で処理する
```Kotlin: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には入力欄で表示させたいメッセージを入れます。
```Kotlin: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2524029/b605dfce-9222-25e9-87e7-74ba2b5fb8c2.png)