Mokelab Blog

#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つ前の画面に戻る処理は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()の中で実行する必要があります。 公式ドキュメントはこちらにあるので、あわせて確認してみてください。

保存後の処理を記述する

最後にdonetrueがセットされ、保存が完了した状態になった後の処理を記述します。もっといい方法があったら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)
  }
}

記事としては長くなりましたが、やってることは割と直感的な気がします。

ここまで作業したものはこちらにあります。

本サイトではサービス向上のため、Google Analyticsを導入しています。分析にはCookieを利用しています。