Mokelab Blog

#56 AndroidでToDoアプリを作る - ToDo作成画面を作る

今回はToDo作成画面を作り、データベースにToDoを保存できるようにします。

画面レイアウトを作る

ToDoのタイトルと詳細を入力するためのEditTextを配置します。タイトルは1行テキストで、詳細は複数行を入力できるようにします。

EditTextはそのまま配置せず、TextInputLayoutを配置し、その中にいれるようにします。TextInputLayoutは、ヒント文字列をフローティングさせたり、文字数カウンタなどを表示させたりするためのレイアウトです。PaletteでTextInputLayoutを検索し、レイアウトに貼り付けるとよいでしょう。

また、以下の設定を行います。

レイアウトXMLは次のようになります。とても長いのでみたい方のみ表示ボタンを押してください。

マテリアルデザインライブラリ

TextInputLayoutを配置すると、プロジェクト作成時の状況によっては次のように下線のみの入力欄が表示される場合があります。

これは、アプリで使用しているテーマがTheme.MaterialComponentsではなく、Theme.AppCompatだったりするのが原因です。

マテリアルデザインのテーマを使用することで、他アプリにあわせたユーザー体験が提供できます。app/build.gradleにライブラリを追加しましょう。

dependencies {
  ...
  implementation 'com.google.android.material:material:1.2.1'
}

ライブラリを追加したら、右上のSync nowをクリックするのを忘れないようにします。

そして、res/values/styles.xml(or themes.xml)のAppThemeの親を変更します。

<resources>
  <!-- Base application theme. -->
  <style name="AppTheme" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
    ...
  </style>
</resources>

最後にAndroid Studioの上メニューの「Build」→「Make Project」を選び、一度ビルドします。この作業をしないとレイアウトエディタでの表示が更新されません。

メニューを作る

次に、ToDo保存ボタンとなるメニューを作ります。resフォルダにmenuフォルダを作り、その中に「File」→「New」→「Menu Resource File」でメニュー用XMLファイルを作ります。ファイル名はmenu_createにしておきます。

保存を表すアイコンをアプリに追加していないので、Vector assetで追加します。保存を表すsaveアイコンも提供されていますが、スマホアプリでほとんど見かけません(知っていたらTwitterで教えてください)。完了を表す「done」アイコンを追加します。

ここからmenu_create.xmlの中身の編集です。メニューの項目を追加するには、レイアウトエディタと同様に左上のPaletteからMenuItemを選び、中央の画面にドラッグします。

追加したメニューの項目に対し、次の設定をします。

XMLは次のようになります。

<menu xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:android="http://schemas.android.com/apk/res/android">
  <item
      android:id="@+id/action_save"
      android:icon="@drawable/ic_baseline_done_24"
      android:title="@string/save"
      app:showAsAction="ifRoom" />
</menu>

このmenu_create.xmlをToDo作成画面で使うための処理を記述していきます。まず、onCreate()をオーバーライドし、setHasOptionsMenu(true)を呼びます。

@AndroidEntryPoint
class CreateToDoFragment: Fragment(R.layout.create_todo_fragment) {
  ...
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setHasOptionsMenu(true)
  }
}

次に、onCreateOptionsMenu()をオーバーライドし、引数で渡されるMenuInflaterを使ってMenuをXMLリソースから作ります。

@AndroidEntryPoint
class CreateToDoFragment: Fragment(R.layout.create_todo_fragment) {
  ...      
  override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    super.onCreateOptionsMenu(menu, inflater)
    // MenuInflaterを使ってXMLリソースからメニューを作る
    inflater.inflate(R.menu.menu_create, menu)
  }
}

ここまで書いたら実行し、ToDo作成画面を表示してみます。右上にDoneアイコンが表示されていればメニューの設定はできています。

保存処理を記述する

メニューのDoneボタンをタップした際の処理を記述していきます。メニュー項目をタップした際の処理はonOptionsItemSelected()に記述します。

class CreateToDoFragment: Fragment(R.layout.create_todo_fragment) {
  private val vm: CreateToDoViewModel by viewModels()
  private var _binding: CreateTodoFragmentBinding? = null
  private val binding: CreateTodoFragmentBinding get() = _binding!!
  ...
  override fun onOptionsItemSelected(item: MenuItem) =
    when (item.itemId) {
      R.id.action_save -> {
        save()
        true
      }
      else -> super.onOptionsItemSelected(item)
    }
  
  private fun save() {
    // 入力内容を取得して
    val title = binding.titleEdit.text.toString()
    val detail = binding.detailEdit.text.toString()
    // ViewModelに渡すのみ
    vm.save(title, detail)
  }
}

タップされた際、入力内容を取得して保存処理を開始します。 CreateToDoViewModelsave()メソッドはまだ作っていないので、作ります。Alt+Enterを使ってAndroid Studioに作らせるのがよいでしょう。

class CreateToDoViewModel: ViewModel() {
  fun save(title: String, detail: String) {
    // タイトルが空っぽだったらエラーメッセージを出す
    // リポジトリ経由で実際の保存処理を行う
  }
}

エラーメッセージの表示はLiveData経由でやってみます。MutableLiveDataをプロパティとして用意し、エラーとなった場合に書き込みます。

class CreateToDoViewModel: ViewModel() {
  val errorMessage = MutableLiveData<String>()

  fun save(title: String, detail: String) {
      // タイトルが空っぽだったらエラーメッセージを出す
      if (title.trim().isEmpty()) {
          errorMessage.value = "Please input title"
          return
      }
      // リポジトリ経由で実際の保存処理を行う
  }
}

フラグメント側でerrorMessageを監視する処理も書きましょう。今回はSnackbarを使ってみます。

class CreateToDoFragment: Fragment(R.layout.create_todo_fragment) {
  private val vm: CreateToDoViewModel by viewModels()
  ...
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
      super.onViewCreated(view, savedInstanceState)
      this._binding = CreateTodoFragmentBinding.bind(view)

      vm.errorMessage.observe(viewLifecycleOwner) { msg ->
          if (msg.isEmpty()) return@observe
          Snackbar.make(requireView(), msg, Snackbar.LENGTH_SHORT).show()
          // 画面回転するともう一度エラーが表示されちゃうのを防止
          vm.errorMessage.value = ""
      }
  }
}

ViewModel用Hiltライブラリの追加

エラーチェックの後はリポジトリの回で作成したToDoリポジトリの作成メソッドを呼びます。次のようにコンストラクタでToDoRepositoryを受け取るようにします。

class CreateToDoViewModel(
  private val repo: ToDoRepository
): ViewModel() { ... }

コンストラクタを追加しアプリを実行すると、ToDo作成画面を表示しようとした時点でクラッシュします。ログには次のようなメッセージが表示されます。

java.lang.RuntimeException:
  Cannot create an instance of class com.mokelab.mytodo.page.create.CreateToDoViewModel

ViewModelのオブジェクトはby viewModels()で作成されるので、引数なしコンストラクタである必要があります(AndroidViewModelの場合はApplicationのみ受け取るコンストラクタ)。

ここで登場するのがViewModel用Hiltライブラリです。これを使うと

といったメリットがあります。公式ドキュメントはこちらです(英語)。

まず、app/build.gradleにライブラリを追加します。

dependencies {
  ...
  implementation 'androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03'
  kapt 'androidx.hilt:hilt-compiler:1.0.0-alpha03'
}

ライブラリを追加したら、右上の「Sync now」をクリックするのを忘れないようにします。

そして、CreateToDoViewModelクラスに@HiltViewModelをつけ、コンストラクタに@Inject constructorをつけます。これだけでコンストラクタの引数となっているオブジェクトをHiltが作り、差し込んでくれます。

@HiltViewModel
class CreateToDoViewModel @ViewModelInject constructor(
  private val repo: ToDoRepository
): ViewModel() { ... }

なお、by viewModels()でセットするViewModelに@HiltViewModelがついている場合、そのフラグメントには@AndroidEntryPointをつける必要があります。もしつけ忘れていた場合、単純に引数ありコンストラクタを追加したときと同じRuntimeExceptionが発生します。

ToDoRepositoryをHilt経由でもらうことに成功したので、さっそくToDoを保存させてみます。保存処理は非同期で行われるので、viewModelScopeの中で実行します。こうすることで、保存処理中に画面回転が発生しても処理は継続され、バックボタンなどでViewModelが破棄されるタイミングでキャンセルされるようになります。完了したらLiveDataに「終わったよ」をセットしUI側に伝えることにします。

@HiltViewModel
class CreateToDoViewModel @Inject constructor(
  private val repo: ToDoRepository
) : ViewModel() {
  val errorMessage = MutableLiveData<String>()
  val done = MutableLiveData<Boolean>()

  fun save(title: String, detail: String) {
    // タイトルが空っぽだったらエラーメッセージを出す
    if (title.trim().isEmpty()) {
      errorMessage.value = "Please input title"
      return
    }
    // リポジトリ経由で実際の保存処理を行う
    viewModelScope.launch {
      try {
        repo.create(title, detail)
        done.value = true
      } catch (e: Exception) {
        errorMessage.value = e.message
      }
    }
  }
}

doneの中身もフラグメント側で監視し、終わったら1つ前の画面に戻るようにしましょう。1つ前の画面に戻るには、を呼びます。

class CreateToDoFragment : Fragment(R.layout.create_todo_fragment) {
  private val vm: CreateToDoViewModel by viewModels()
  ...
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    this._binding = CreateTodoFragmentBinding.bind(view)

    vm.errorMessage.observe(viewLifecycleOwner) { msg ->
      ...
    }
    vm.done.observe(viewLifecycleOwner) { done ->
      if (!done) return@observe
      // 1つ前の画面に戻る
      findNavController().popBackStack()
    }
  }
}

これで保存処理が記述できました。

実行してみる

タイトルと詳細を入力し、右上のボタンを押すとToDo保存処理が行われ、完了すると1つ前の画面に戻っています。

Database Inspector

ToDoの保存は行われているようですが、現時点でアプリに表示する機能がないため、本当に保存できているかわかりません。そこで登場するのがDatabase Inspectorです。アプリ内のデータベースの内容を確認することができます。Android Studio 4.1で追加された機能で、API 26以降の環境でデバッグ実行した際に使えます。

Database Inspectorを使うには、Android Studioの上メニューの「View」→「Tool Windows」→「Database Inspector」を選びます。すでにウィンドウの一番下にタブとして表示されている場合は、タブ部分をクリックします。

Database Inspectorが正しく動作した場合、左側にアプリ内のデータベースとテーブルが表示されます。

ToDoテーブルをダブルクリックすると、右側にテーブルの内容が表示されます。動作確認で追加したToDoがちゃんと保存されていることが確認できました。

まとめ

今回はToDo作成画面を作り、データベースにToDoを保存できるようにしました。ViewModelを使った処理は他のアプリ開発でも有用なので、マスターしておきましょう。

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