Mokelab Blog

#60 AndroidでToDoアプリを作る - ToDoの編集機能

今回はToDoの編集機能を実装してみます。

メニューから編集画面への遷移

編集機能はToDoの詳細を表示している画面からできるようにしてみます。メニューにアイコンを追加し、遷移部分を実装していきます。

まず、編集を表すアイコンがないので、Vector assetsから編集を探して追加します。今回はeditというアイコンを追加します。

次にメニューXMLを作ります。作り方はToDo作成画面を作るの回で説明したものと同じです。ファイル名は詳細画面用のメニューなのでmenu_detail.xmlにします。アイテムを1つ追加し、IDを@+id/action_editにします。

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:android="http://schemas.android.com/apk/res/android">
  
  <item
    android:id="@+id/action_edit"
    android:icon="@drawable/ic_baseline_edit_24"
    android:title="@string/edit"
    app:showAsAction="ifRoom" />
</menu>

メニューXMLを追加したら、フラグメント側で使うための実装を行います。onCreate()setHasOptionsMenu(true)を呼び、onCreateOptionsMenuで先ほど作成したメニューXMLからメニューを作ります。

class ToDoDetailFragment: Fragment(R.layout.todo_detail_fragment) {
  ...

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setHasOptionsMenu(true)
  }

  override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    super.onCreateOptionsMenu(menu, inflater)
    inflater.inflate(R.menu.menu_detail, menu)
  }
}

この節の最後として、メニューのアイテムタップ時の処理を記述します。

class ToDoDetailFragment: Fragment(R.layout.todo_detail_fragment) {
  ... 

  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return when (item.itemId) {
      R.id.action_edit -> {
        findNavController().navigate(R.id.action_toDoDetailFragment_to_editToDoFragment)
        true
      }
      else -> super.onOptionsItemSelected(item)
    }
  }
}

navigate()を呼ぶと編集画面に遷移はできますが、どのToDoを編集するかの情報が渡せていません。ナビゲーショングラフにパラメータを追加し、渡せるようにします。パラメータ名はtodoにしておきます。

ナビゲーショングラフにパラメータを追加したので、ToDoDetailFragmentDirectionsクラスがちゃんと更新されるよう、一旦ビルドしておきます。

class ToDoDetailFragment: Fragment(R.layout.todo_detail_fragment) {
  ...

  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return when (item.itemId) {
      R.id.action_edit -> {
        val action = ToDoDetailFragmentDirections.actionToDoDetailFragmentToEditToDoFragment(
            args.todo
        )
        findNavController().navigate(action)
        true
      }
      else -> super.onOptionsItemSelected(item)
    }
  }
}

ToDo編集画面を作る

画面遷移まで実装できたので、次は編集画面側の実装をやっていきます。

まず画面レイアウトです。ToDo作成画面と同じレイアウトで問題ないと思うので、create_todo_fragment.xmlの内容をそのままedit_todo_fragment.xmlに貼り付けます。同じレイアウトなので同じレイアウトXMLを使う方法が思いつきます。しかし、「編集時はこの項目は編集させたくない」といった仕様リクエストが来るケースを考慮し、今回は別のレイアウトXMLファイルにしました。

次に、EditToDoFragmentでViewBindingの準備をします。

class EditToDoFragment: Fragment(R.layout.edit_todo_fragment) {
  private val vm: EditToDoViewModel by viewModels()

  private var _binding: EditTodoFragmentBinding? = null
  private val binding: EditTodoFragmentBinding get() = _binding!!

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    this._binding = EditTodoFragmentBinding.bind(view)
  }

  override fun onDestroyView() {
    super.onDestroyView()
    this._binding = null
  }
}

そして、画面遷移時のパラメータを受け取り、EditTextの初期値としてセットしてみます。

class EditToDoFragment: Fragment(R.layout.edit_todo_fragment) {
  ...
  private val args: EditToDoFragmentArgs by navArgs()

  private var _binding: EditTodoFragmentBinding? = null
  private val binding: EditTodoFragmentBinding get() = _binding!!

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    this._binding = EditTodoFragmentBinding.bind(view)

    val todo = args.todo
    binding.titleEdit.setText(todo.title)
    binding.detailEdit.setText(todo.detail)
  }
  ...
}

ToDo作成画面のときと同様に、編集を完了させるためのボタンをメニューとして作ります。ファイル名をmenu_edit.xmlとし、中身はmenu_create.xmlと同じものにします。

<?xml version="1.0" encoding="utf-8"?>
<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>

メニューXMLを作ったら、EditToDoFragmentで使うための記述をします。

class EditToDoFragment: Fragment(R.layout.edit_todo_fragment) {
  ...
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setHasOptionsMenu(true)
  }
  ...
  override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    super.onCreateOptionsMenu(menu, inflater)
    inflater.inflate(R.menu.menu_edit, menu)
  }
}

ToDo更新処理を記述する

編集画面に配置したメニューのアイテムをタップしたとき、更新処理が行われるようにします。ToDo作成画面の時と同様に、ViewModelにsave()メソッドを追加し、具体的な処理はViewModelに任せます。

class EditToDoFragment: Fragment(R.layout.edit_todo_fragment) {
  private val vm: EditToDoViewModel by viewModels()
  private val args: EditToDoFragmentArgs by navArgs()

  private var _binding: EditTodoFragmentBinding? = null
  private val binding: EditTodoFragmentBinding get() = _binding!!

  ...

  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return 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()
    
    vm.save(args.todo, title, detail)
  }
}

EditToDoViewModelでは、ToDo作成画面のときと同様にコンストラクタでリポジトリを受け取るようにします。

@HiltViewModel
class EditToDoViewModel @Inject constructor(
  private val repo: ToDoRepository
): ViewModel() {
  fun save(todo: ToDo, title: String, detail: String) {
    TODO("Not yet implemented")
  }
}

そして、これまたToDo作成時と同様に入力チェックを加えます。

class EditToDoViewModel @Inject constructor(
  private val repo: ToDoRepository
): ViewModel() {
  val errorMessage = MutableLiveData<String>()

  fun save(todo: ToDo, title: String, detail: String) {
    // タイトルが空っぽだったらエラーメッセージを出す
    if (title.trim().isEmpty()) {
      errorMessage.value = "Please input title"
      return
    }
  }
}

実際の更新処理はリポジトリに任せるようにします。update()というメソッドを追加します。

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

  fun save(todo: ToDo, title: String, detail: String) {
    // タイトルが空っぽだったらエラーメッセージを出す
    if (title.trim().isEmpty()) {
      errorMessage.value = "Please input title"
      return
    }
    viewModelScope.launch {
      try {
        repo.update(todo, title, detail)
        done.value = true
      } catch (e: Exception) {
        errorMessage.value = e.message
      }
    }
  }
}

リポジトリのupdate()メソッドはsuspendにしておきます。

interface ToDoRepository {
  fun getAll(): Flow<List<ToDo>>
  suspend fun create(title: String, detail: String)
  suspend fun update(todo: ToDo, title: String, detail: String)
}

実装側では、更新用のToDoオブジェクトを新たに生成し、DAOのメソッドを呼ぶことにします。引数として受け取っているToDoの各プロパティをvalからvarに変更し、それをDAOのupdate()に渡す方法も考えられますが、これだと更新途中で失敗した際、インスタンスの内容を元に戻す処理が必要となってしまいます。

class ToDoRepositoryImpl @Inject constructor(
  private val dao: ToDoDAO
): ToDoRepository {
  ...

  override suspend fun update(todo: ToDo, title: String, detail: String) {
    val updateToDo = ToDo(
      _id = todo._id,
      title = title,
      detail = detail,
      created = todo.created,
      modified = System.currentTimeMillis()
    )
    withContext(Dispatchers.IO) {
      dao.update(updateToDo)
    }
  }
}

フラグメント側でLiveDataの監視処理を記述します。

class EditToDoFragment: Fragment(R.layout.edit_todo_fragment) {
  private val vm: EditToDoViewModel by viewModels()
  ...
  private var _binding: EditTodoFragmentBinding? = null
  private val binding: EditTodoFragmentBinding get() = _binding!!
  ...
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    this._binding = EditTodoFragmentBinding.bind(view)

    val todo = args.todo
    binding.titleEdit.setText(todo.title)
    binding.detailEdit.setText(todo.detail)

    vm.errorMessage.observe(viewLifecycleOwner) { msg ->
      if (msg.isEmpty()) return@observe

      Snackbar.make(requireView(), msg, Snackbar.LENGTH_SHORT).show()
      vm.errorMessage.value = ""
    }
    vm.done.observe(viewLifecycleOwner) {
      findNavController().popBackStack()
    }
  }
  ...
}

動作確認してみる

ここまで実装したら動作確認をしてみましょう。

編集が完了した後、詳細画面に戻りましたが内容は編集前のままです。この動作だとユーザーはびっくりしてしまうので修正しましょう。

呼び出し元フラグメントに結果を伝える

ToDoの更新が成功した際、その結果を呼び出し元に伝える仕組みを作ります。

まず、リポジトリのupdate()メソッドが更新結果のToDoを返すようにします。

interface ToDoRepository {
  ...
  suspend fun update(todo: ToDo, title: String, detail: String): ToDo
}

実装側も修正しておきます。

class ToDoRepositoryImpl @Inject constructor(
  private val dao: ToDoDAO
): ToDoRepository {
  ...

  override suspend fun update(todo: ToDo, title: String, detail: String): ToDo {
    val updateToDo = ToDo(
      _id = todo._id,
      title = title,
      detail = detail,
      created = todo.created,
      modified = System.currentTimeMillis(),
    )
    withContext(Dispatchers.IO) {
      dao.update(updateToDo)
    }
    return updateToDo
  }
}

ViewModel側では、doneの型パラメータをBooleanからToDoに変更し、更新結果を渡せるようにします。

class EditToDoViewModel @Inject constructor(
  private val repo: ToDoRepository
): ViewModel() {
  ...
  val done = MutableLiveData<ToDo>()
  
    fun save(todo: ToDo, title: String, detail: String) {
      ...
      viewModelScope.launch {
        try {
          val updated = repo.update(todo, title, detail)
          done.value = updated
        } catch (e: Exception) {
          errorMessage.value = e.message
        }
      }
    }
  }

フラグメント側も修正していきます。呼び出し元フラグメントに結果を返すにはsetFragmentResult()を使います。引数には結果をBundleにしたものを渡します。

class EditToDoFragment: Fragment(R.layout.edit_todo_fragment) {
  ...
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    this._binding = EditTodoFragmentBinding.bind(view)
    ...
    vm.done.observe(viewLifecycleOwner) { todo ->
      val data = bundleOf("todo" to todo)
      setFragmentResult("edit", data)

      findNavController().popBackStack()
    }
  }
  ...
}

詳細画面のフラグメントで、この結果を受け取る部分を記述します。onCreate()でリスナーを登録します。

class ToDoDetailFragment: Fragment(R.layout.todo_detail_fragment) {
  ...
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setHasOptionsMenu(true)
    setFragmentResultListener("edit") { _, data ->
        val todo: ToDo = data.getParcelable("todo")!!
        // 値をセット
        binding.titleText.text = todo.title
        binding.detailText.text = todo.detail
    }
  }
  ...
}

これで、編集結果を詳細画面に伝えれるようになりました。しかしまだまだ問題があります。

中断再開に対応する

ToDoを編集し、詳細画面に戻ったあと、アプリを中断・再開すると、次のように編集前の内容に戻ってしまうことがあります。

これは、詳細画面で扱っているToDoの状態が中断・再開によって失われているのが原因です。対応していきましょう。

画面の状態を扱うので、ViewModelにMutableLiveDataを追加します。まず、コンストラクタにSavedStateHandleを追加します。これは中断・再開時に状態の保存と復元をやるために使います。そしてこれ経由でMutableLiveDataを作ります。

class ToDoDetailViewModel(
  state: SavedStateHandle
): ViewModel() {
  val todo = state.getLiveData<ToDo>("todo")
}

フラグメント側では、値のセットを他の画面と同様にLiveDataの監視で行うようにします。

class ToDoDetailFragment: Fragment(R.layout.todo_detail_fragment) {
  private val vm: ToDoDetailViewModel by viewModels()
  ...
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    this._binding = TodoDetailFragmentBinding.bind(view)

    vm.todo.observe(viewLifecycleOwner) { todo ->
      binding.titleText.text = todo.title
      binding.detailText.text = todo.detail
    }
  }
  ...
}

そして、結果を受け取るリスナーでは結果をLiveDataにセットするようにします。また、初期値はsavedInstanceStatenullの時だけセットされるようにします。

class ToDoDetailFragment: Fragment(R.layout.todo_detail_fragment) {
  ...
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setHasOptionsMenu(true)
    setFragmentResultListener("edit") { _, data ->
      val todo: ToDo = data.getParcelable("todo")!!
      vm.todo.value = todo
    }
    if (savedInstanceState == null) {
      vm.todo.value = args.todo
    }
  }
  ...
}

ViewModelの状態を正としたので、画面遷移の部分も修正します。

class ToDoDetailFragment: Fragment(R.layout.todo_detail_fragment) {
  ...
  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    return when (item.itemId) {
      R.id.action_edit -> {
        val action = ToDoDetailFragmentDirections.actionToDoDetailFragmentToEditToDoFragment(
            vm.todo.value!!
        )
        findNavController().navigate(action)
        true
      }
      else -> super.onOptionsItemSelected(item)
    }
  }
}

もう一度動作確認をしてみましょう。中断・再開を挟んでも状態が復元されていることが確認できました。

まとめ

ToDo編集機能を実装してみました。とても長い記事となりましたが、やっていることはToDo作成画面を作るときと同様なものも多いので、ぜひチャレンジしてみてください。

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