Mokelab Blog

#57 AndroidでToDoアプリを作る - ToDoをリスト表示する

今回は、データベースに保存しているToDoをリスト表示してみます。

RecyclerView

RecyclerViewとは、大量のデータを効率的にリスト表示してくれるビューです。リストのスクロールによって表示されなくなったビューをリサイクルすることで、少ないメモリ消費量で大量のデータを扱えるようになっています。

RecyclerViewはいろんなライブラリの依存関係として追加されていますが、バージョンを固定するためにapp/build.gradleに追加します。

dependencies {
  ...
  implementation "androidx.recyclerview:recyclerview:1.2.0-beta01"
}

次に、ToDoをリスト表示したい画面にRecyclerViewを貼ります。今回は起動直後の画面でリスト表示したいので、main_fragment.xmlに貼ります。画面全体に表示されるよう、制約をつけておきます。

IDは@+id/recyclerにしておきます。レイアウトXMLは次のようになります。

<androidx.constraintlayout.widget.ConstraintLayout
  ...
  >

  <androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recycler"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"/>

  <com.google.android.material.floatingactionbutton.FloatingActionButton
    ...
    />

</androidx.constraintlayout.widget.ConstraintLayout>

1行分のレイアウトを作る

次に、1行分のレイアウトを作ります。res/layoutに、todo_item.xmlというレイアウトXMLを作ります。

まず、親となるConstraintLayoutの高さをmatch_parentからwrap_contentにします。これを忘れるとリスト1行が1画面分として表示されてしまいます。また、高さの最小値を64dpにしておきます。

そして、TextViewを2つ並べ、次の表示となるよう制約やマージンなどをつけていきます。タイトル表示用のTextViewを@+id/title_text、作成時刻表示用のTextViewを@+id/created_textとします。

@+id/title_textのほうには、次のように制約やマージンをつけていきます。

@+id/created_textのほうには、次のように制約やマージンをつけていきます。

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

アダプターを作る

1行分のレイアウトを作ったら、次はRecyclerView用のアダプターを作ります。RecyclerViewはアダプターに問い合わせをすることでリスト表示を実現しています。

手順が多いので順に説明します。まず、ListAdapterを継承したクラスを作ります。同名のクラスが2つありますが、androidx.recyclerview.widget.ListAdapterのほうを使います。

class ToDoAdapter: ListAdapter<ToDo, ToDoAdapter,ViewHolder>() {
}

この時点では、ToDoAdapterにViewHolderというクラスを作っていないので赤文字になります。ToDoAdapterの内部クラスとして作ります。

class ToDoAdapter : ListAdapter<ToDo, ToDoAdapter.ViewHolder>() {
  class ViewHolder(
      private val binding: TodoItemBinding
  ) : RecyclerView.ViewHolder(binding.root) {
  }
}

ViewHolderとは、1行分のビューを保持しておくためのクラスです。値をセットする際、毎回findViewById()を呼ぶと時間がかかってしまうため、あらかじめ値をセットするためのビューを取り出しておきます。

まだまだ赤線は続きます。ListAdapterのプライマリーコンストラクタは引数を1つ必要とします。要素を比較するためのオブジェクトを渡す必要があるので、companion objectの中で作ります。そしてプライマリーコンストラクタの引数に渡します。

class ToDoAdapter : ListAdapter<ToDo, ToDoAdapter.ViewHolder>(callbacks) {
  ...

  companion object {
    private val callbacks = object : DiffUtil.ItemCallback<ToDo>() {
      // 同じアイテムかどうかを調べる
      override fun areItemsTheSame(oldItem: ToDo, newItem: ToDo): Boolean {
        return oldItem._id == newItem._id
      }

      // 同じアイテムの時に、表示内容が同じかどうかを調べる
      override fun areContentsTheSame(oldItem: ToDo, newItem: ToDo): Boolean {
        return oldItem.title == newItem.title &&
                oldItem.created == newItem.created
      }
    }
  }
}

areItemsTheSameは2つのアイテムが同じものかを調べます。今回はToDoのIDが同じかどうかで調べます。areContentsTheSameは2つのアイテムの表示内容が同じかどうかを調べます。こちらはareItemsTheSametrueを返したときに呼ばれ、リストの表示内容を更新すべきかどうかの判定で使われます。

最後に、実装すべきメソッドを実装します。class ToDoAdapterのToDoAdapterのところにカーソルを移動させ、Alt+Enterで必要となるメソッドを追加します。

onCreateViewHolderでは、1行分のビューを作り、先ほど定義したViewHolderに詰めて返します。

class ToDoAdapter : ListAdapter<ToDo, ToDoAdapter.ViewHolder>(callbacks) {
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    val inflater = LayoutInflater.from(parent.context)
    // parent, false で呼ぶ
    val binding = TodoItemBinding.inflate(inflater, parent, false)
    return ViewHolder(binding)
  }
  ...
}

onBindViewHolderでは、ViewHolderとリスト中での場所が渡されます。ここではビューに値をセットしていきます。getItem(position)で、アダプターにセットされたToDoを取得することができます。ViewHolderに設定メソッドを作り、その中で行うとコードがすっきりするでしょう。

class ToDoAdapter : ListAdapter<ToDo, ToDoAdapter.ViewHolder>(callbacks) {
  ...
  override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    val todo = getItem(position)
    holder.bindTo(todo)
  }

  class ViewHolder(
    private val binding: TodoItemBinding
  ) : RecyclerView.ViewHolder(binding.root) {
    fun bindTo(todo: ToDo) {
      binding.titleText.text = todo.title
      binding.createdText.text =
        DateFormat.format("yyyy-MM-dd hh:mm:ss", todo.created)
    }
  }
  ...
}

これでアダプターができました。

RecyclerViewの準備をする

ToDoAdapterができたら、フラグメント側でRecyclerViewにセットします。

@AndroidEntryPoint
class MainFragment : Fragment(R.layout.main_fragment) {
  ...

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

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    this._binding = MainFragmentBinding.bind(view)
    ...
    val adapter = ToDoAdapter()
    binding.recycler.adapter = adapter
  }
  ...
}

そしてビューの並べ方を指示するためのレイアウトマネージャを指定します。Kotlinコードでも指定できますが、縦一列に並べるだけの場合はレイアウトXMLで指定することもできます。 main_fragment.xmlで、RecyclerViewにapp:layoutManager属性を追加します。

<androidx.constraintlayout.widget.ConstraintLayout
  ...
  >

  <androidx.recyclerview.widget.RecyclerView
    ...
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

  <com.google.android.material.floatingactionbutton.FloatingActionButton
    ...
    />
</androidx.constraintlayout.widget.ConstraintLayout>

ToDoを取得しアダプターにセットする

最後にToDoをリポジトリから取得し、アダプターにセットする部分です。

まず、ToDoRepositoryにToDoをすべて取得するためのメソッドを追加します。

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

そして、ToDoRepositoryImplで中身を実装します。今回はDAOからとってきたものをそのまま返すことにします。

class ToDoRepositoryImpl @Inject constructor(
  private val dao: ToDoDAO
) : ToDoRepository {
  ...
  override fun getAll(): Flow<List<ToDo>> {
    return dao.getAll()
  }
}

ToDoDAOgetAll()がなかったので、こちらも作ります。

@Dao
interface ToDoDAO {
  ...
  @Query("select * from ToDo order by created desc")
  fun getAll(): Flow<List<ToDo>>
  ...
}

リポジトリ側の準備ができたので、次はFlow<T>をLiveDataにしてViewModelにもたせます。lifecycle-livedata-ktxにasLiveData()という便利な拡張関数が用意されているので、app/build.gradleのdependenciesに追加します。

dependencies {
  ...
  implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0'
}

追加したら右上の「Sync now」を押すのを忘れないようにしましょう。

MainViewModelのコンストラクタでリポジトリを受け取り、次のようにLiveDataをプロパティとしてもたせます。

@HiltViewModel
class MainViewModel @Inject constructor(
  private val repo: ToDoRepository
) : ViewModel() {
  val todoList = repo.getAll().asLiveData()
}

最後にフラグメント側でこのLiveDataを監視します。リポジトリから新しいToDoのリストを受け取ったら、submitList()でアダプターに渡します。

@AndroidEntryPoint
class MainFragment : Fragment(R.layout.main_fragment) {
  private val vm: MainViewModel by viewModels()
  ...
  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)
    ...
    val adapter = ToDoAdapter()
    binding.recycler.adapter = adapter

    vm.todoList.observe(viewLifecycleOwner) { list ->
      adapter.submitList(list)
    }
  }
  ...
}

動作確認してみる

ここまでできたらアプリを実行してみましょう。追加したToDoがリストで表示されれば成功です。

さらにToDoを1つ追加し、表示させてみます。

ちゃんと2件表示されました。

まとめ

RecyclerViewを使ってToDoをリスト表示させてみました。ListAdapterは準備が大変ですが、リストの差分を効率的に扱ってくれるので、使えるようになっておきましょう。

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