#71 Jetpack ComposeでToDoアプリを作る - ToDo作成画面
今回はToDo作成画面の実装をやってみます。結構長くなりそう。。。
Scaffoldをいれる
まずはベースとなるScaffoldを追加します。
@Composable
fun CreateToDoScreen(
navController: NavController,
viewModel: CreateToDoViewModel,
) {
// Scaffoldを追加した
Scaffold(
topBar = {
CreateTopBar(navController)
},
) {
CreateToDoBody()
}
}
レイアウトを作る
CreateToDoBody()
で画面レイアウトを作ります。ToDoのタイトル入力欄は1行で、詳細部分は残り全部で表示するようにしてみます。
@Composable
fun CreateToDoBody() {
Column {
TextField(
...
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
TextField(
...
modifier = Modifier
.weight(1.0f, true)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
Modifier.weight(1.0f, true)
をつけると、LinearLayoutのように比率でサイズを決定してくれます。
テキスト入力状態を追加する
TextField
は入力中の文字列を状態として用意し、更新する必要があります。状態はScreenを表すComposable側に持たせたほうがよいので、ここでは引数として受け取ることにします。
@Composable
fun CreateToDoBody(
title: MutableState<String>,
detail: MutableState<String>,
) {
Column {
TextField(
value = title.value,
onValueChange = { title.value = it },
label = { Text(stringResource(id = R.string.todo_title)) },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
TextField(
value = detail.value,
onValueChange = { detail.value = it },
label = { Text(stringResource(id = R.string.todo_detail)) },
modifier = Modifier
.weight(1.0f, true)
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
Screen Composable側に状態を追加します。
@Composable
fun CreateToDoScreen(
navController: NavController,
viewModel: CreateToDoViewModel,
) {
// 入力状態を追加
val title = rememberSaveable { mutableStateOf("") }
val detail = rememberSaveable { mutableStateOf("") }
Scaffold(
topBar = {
CreateTopBar(navController)
},
) {
// 引数として渡す
CreateToDoBody(title, detail)
}
}
TopAppBarを作る
次はCreateTopBar(navController)
です。役割は次の2つにします。
- 左上の戻るボタンで1つ前の画面に戻る
- 右上の完了ボタンで保存処理を行う
それぞれラムダ式を受け取るようにしたほうがよさそうですが、1つ前の画面に戻る処理はnavControllerを直接渡して呼んでもらうことにします。
@Composable
fun CreateTopBar(navController: NavController) {
TopAppBar(
navigationIcon = {
IconButton(onClick = {
// 1つ前の画面に戻る
navController.popBackStack()
}) {
Icon(Icons.Filled.ArrowBack, "Back")
}
},
title = {
Text(stringResource(id = R.string.create_todo))
},
)
}
右上に完了ボタンを配置し、保存処理を実行してもらいます。ラムダ式を引数として受け取ることにします。
@Composable
fun CreateTopBar(navController: NavController, save: () -> Unit) {
TopAppBar(
navigationIcon = {
IconButton(onClick = {
navController.popBackStack()
}) {
Icon(Icons.Filled.ArrowBack, "Back")
}
},
title = {
Text(stringResource(id = R.string.create_todo))
},
// アクションとして追加
actions = {
// タップされたときの処理は親で決める
IconButton(onClick = save) {
Icon(Icons.Filled.Done, "Save")
}
}
)
}
Screen側も修正します。
@Composable
fun CreateToDoScreen(
navController: NavController,
viewModel: CreateToDoViewModel,
) {
val title = rememberSaveable { mutableStateOf("") }
val detail = rememberSaveable { mutableStateOf("") }
Scaffold(
scaffoldState = scaffoldState,
topBar = {
CreateTopBar(navController) {
// 実際の処理はViewModelにやらせる
viewModel.save(title.value, detail.value)
}
},
) {
CreateToDoBody(title, detail)
}
}
ViewModelで保存処理の実装
画面レイアウトができたので、次はViewModelで保存処理の実装をやっていきます。View System版と同様にタイトルが空っぽだったらエラーにします。
@HiltViewModel
class CreateToDoViewModel @Inject constructor(): ViewModel() {
// MutableStateFlowで用意
val errorMessage = MutableStateFlow("")
val done = MutableStateFlow(false)
fun save(title: String, detail: String) {
if (title.trim().isEmpty()) {
errorMessage.value = "Input title"
return
}
viewModelScope.launch {
...
}
}
}
実際にデータベースに書き込む部分はToDoRepository
を作り、それに任せることにします。こうすることでRoomのバージョンアップの影響を緩和することができます。コンストラクタで受け取り、Hiltに差し込んでもらいます。
@HiltViewModel
class CreateToDoViewModel @Inject constructor(
// コンストラクタで受け取る
private val repo: ToDoRepository
) : ViewModel() {
val errorMessage = MutableStateFlow("")
val done = MutableStateFlow(false)
fun save(title: String, detail: String) {
if (title.trim().isEmpty()) {
errorMessage.value = "Input title"
return
}
viewModelScope.launch {
try {
repo.create(title, detail)
done.value = true
} catch (e: Exception) {
errorMessage.value = e.message ?: ""
}
}
}
}
ここから先はView
System版で作ったToDoRepository
と同じものを使ってもよいでしょう。
abstract ToDoRepository {
suspend fun create(title: String, detail: String): ToDo
}
class ToDoRepositoryImpl @Inject constructor(
private val dao: ToDoDAO
) : ToDoRepository {
override suspend fun create(title: String, detail: String): ToDo {
val todo = ToDo(
title = title,
detail = detail,
created = System.currentTimeMillis(),
modified = System.currentTimeMillis(),
)
dao.create(todo)
return todo
}
}
Hiltのモジュールも作りましょう。インターフェースと実装を結びつけるだけなので@Binds
が使えますね。
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindToDoRepository(impl: ToDoRepositoryImpl): ToDoRepository
}
エラーをSnackbarで表示する
リポジトリ部分ができたら、次はエラーメッセージの表示部分です。Snackbarで表示させてみます。
ScaffoldStateを用意し、その中にあるsnackbarHostStateを使います。
@Composable
fun CreateToDoScreen(
navController: NavController,
viewModel: CreateToDoViewModel,
) {
// ScaffoldStateを追加
val scaffoldState = rememberScaffoldState()
...
// Flowは状態として扱える
val errorMessage = viewModel.errorMessage.collectAsState()
val done = viewModel.done.collectAsState()
if (errorMessage.value.isNotEmpty()) {
LaunchedEffect(scaffoldState.snackbarHostState) {
scaffoldState.snackbarHostState.showSnackbar(
message = errorMessage.value
)
// 画面回転とかしたら再度表示されちゃうのを抑制
viewModel.errorMessage.value = ""
}
}
...
Scaffold(
// これを追加
scaffoldState = scaffoldState,
...
) {
CreateToDoBody(title, detail)
}
}
showSnackbar()
がsuspend関数なので、LaunchedEffect()
の中で実行する必要があります。
公式ドキュメントはこちらにあるので、あわせて確認してみてください。
保存後の処理を記述する
最後にdone
にtrue
がセットされ、保存が完了した状態になった後の処理を記述します。もっといい方法があったらTwitterの@mokelabで教えてください。
@Composable
fun CreateToDoScreen(
navController: NavController,
viewModel: CreateToDoViewModel,
) {
val scaffoldState = rememberScaffoldState()
val title = rememberSaveable { mutableStateOf("") }
val detail = rememberSaveable { mutableStateOf("") }
val errorMessage = viewModel.errorMessage.collectAsState()
val done = viewModel.done.collectAsState()
if (errorMessage.value.isNotEmpty()) {
LaunchedEffect(scaffoldState.snackbarHostState) {
scaffoldState.snackbarHostState.showSnackbar(
message = errorMessage.value
)
viewModel.errorMessage.value = ""
}
}
if (done.value) {
// 再コンポーズ時にもう一度実行されたら困る
viewModel.done.value = false
navController.popBackStack()
// returnすると一瞬真っ白になっちゃう
}
Scaffold(
scaffoldState = scaffoldState,
topBar = {
CreateTopBar(navController) {
viewModel.save(title.value, detail.value)
}
},
) {
CreateToDoBody(title, detail)
}
}
記事としては長くなりましたが、やってることは割と直感的な気がします。
ここまで作業したものはこちらにあります。