MVVMな感じのRecyclerViewを書く
サークルでアプリを作っているとき、どうやったらいいのかよく分からなかったのでまとめます。
作っていたのは学園祭の当日のパンフレットを代替する目的で作られたアプリで、マップ機能や企画の検索などの機能があります。
今回は企画を検索した際などに企画がリスト状に一覧表示される画面の作り方を載せます。
何を作るのか
イベントの会場情報が一覧で見れるページを作ります。機能としては、ブックマークの追加、削除などがあります。
イメージとしては以下のような感じです。
※参加団体様の名前などが入っているためモザイクをかけています。
以下のテンプレートにサムネや企画名が入ったものがリスト表示されている感じです。
使用した機能とか
RecyclerView
リストを作る際、既存のレイアウトやアダプターだと自由な配置などができなかったりするので、今回はRecyclerViewを使いました。これを採用することでリスト内の各要素がいじりたい放題になり、なんでもござれ状態になります。代わりに設定がややめんどくさくなります。
CustomAdapter
サムネイル画像を設定する際に、ViewModelからビットマップを渡す良い手段がなかったため使用しました。xmlからリソースを指定した際、それに紐付いているCustomAdapterに処理が設定されているとその処理を行うというものです。
実際のコードとか
構成的なやつ
今回掲載するのはマップで企画一覧を見れるようにする際のRecyclerView周りのコードです。
- ViewをゴニョゴニョするためのMapFragment.kt
- そのViewModelにあたるMapViewModel.kt
- RecyclerViewのアダプターとかを記述したMapProjectListAdapter.kt
- MapFragment自体のレイアウトであるfragment_map.xml
- RecyclerViewの各要素のレイアウトを記述したitem_map_project_list.xml
以上の5つを書く必要があります。
いいから早く見せろ
MapFragmentについて
以下のコードがそれですが、特に言うことはないですね。
xmlに記述されているRecyclerViewに、別ファイルで定義してあるアダプターとかを設定するだけです。
ViewModelに格納されている企画データが入っているリストに何らかの変更が生じたら、その変更を観測して新しいリストをRecyclerViewにわたす処理を忘れず設定するようにしましょう。
class MapFragment : Fragment() {
lateinit var binding: FragmentMapBinding
val viewModel : MapViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
// Inflate the layout for this fragment
binding = FragmentMapBinding.inflate(layoutInflater, container, false)
binding.let {
it.lifecycleOwner = viewLifecycleOwner
it.vm = viewModel
}
...
binding.mapDescriptionSpecificRv.layoutManager = LinearLayoutManager(context)
binding.mapDescriptionSpecificRv.adapter = MapProjectListAdapter(this, viewLifecycleOwner, viewModel).also {
viewModel.projectList.observe(viewLifecycleOwner, { list ->
it.submitList(list)
})
}
return binding.root
}
...
}
MapViewModel.ktについて
企画データが入るリストをLiveDataとして宣言しています。リストの型は今回は掲載していませんがエンティティクラスを別で定義しました。それから、お気に入りのオンオフを切り替えるための関数としてtoggleFavorite関数を用意しています。
お気に入りデータを取得する際にファイルの読み書きを行っており、contextを渡す必要があるため、AndroidViewModelを使用しています。
class MapViewModel(application: Application): AndroidViewModel(application) {
...
private val _projectList = MutableLiveData<List<MapProjectItemData>>(emptyList())
val projectList: LiveData<List<MapProjectItemData>>
get() = _projectList.distinctUntilChanged()
private val context = getApplication<Application>().applicationContext
...
fun toggleFavorite(data: MapProjectItemData){
val currentlyChecked = data.isChecked.value?:true
if (data.id != null){
if (currentlyChecked) {
FavoriteProjectsIO.removeFavoriteProject(context, data.id)
} else {
FavoriteProjectsIO.addFavoriteProject(context, data.id)
}
}
data.isChecked.value = !currentlyChecked
}
...
}
MapProjectListAdapter.ktについて
RecyclerViewを使う時に書くいつものやつをひたすら書いてるだけですね。
ListAdapterを継承すると宣言しないといけないクラス等が若干減るので楽です。
it.vm = viewModel
ではMapFragmentに対するViewModelであるMapViewModelを渡し(お気に入り情報を変更する必要がある故)、it.data = item
では企画データが入ったクラスを渡しています。
DiffCallBackはまあいつものおまじないですね。エンティティクラスがdata class
なので比較が楽で助かります。
また、前述したCustomAdapterはここに記述しています。ImageViewのsrcCompatに値を渡すとsetSrcCompatが呼び出される感じです。コメントに書いてあるとおりですが、渡される型で想定しうるものを全て設定し、想定外の物が渡されたときはとりあえずデフォルト画像を設定するようになっています。
class MapProjectListAdapter(
private val calledFragment : Fragment,
private val viewLifecycleOwner: LifecycleOwner,
private val viewModel: MapViewModel
): ListAdapter<MapProjectItemData, MapProjectListAdapter.MapProjectHolder>(DiffCallBack){
class MapProjectHolder(private val binding: ItemMapProjectListBinding): RecyclerView.ViewHolder(binding.root) {
fun bind(item: MapProjectItemData, viewLifecycleOwner: LifecycleOwner, viewModel: MapViewModel, calledFragment: Fragment){
binding.let {
it.lifecycleOwner = viewLifecycleOwner
it.data = item
it.vm = viewModel
...
it.executePendingBindings()
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MapProjectHolder {
val layoutInflater = LayoutInflater.from(parent.context)
return MapProjectHolder(ItemMapProjectListBinding.inflate(layoutInflater, parent, false))
}
override fun onBindViewHolder(holder: MapProjectHolder, position: Int) {
holder.bind(getItem(position), viewLifecycleOwner, viewModel, calledFragment)
}
}
private object DiffCallBack: DiffUtil.ItemCallback<MapProjectItemData>() {
override fun areContentsTheSame(
oldItem: MapProjectItemData,
newItem: MapProjectItemData
): Boolean {
return oldItem == newItem
}
override fun areItemsTheSame(
oldItem: MapProjectItemData,
newItem: MapProjectItemData
): Boolean {
return oldItem.id == newItem.id
}
}
@BindingAdapter("srcCompat")
fun setSrcCompat(view: ImageView, src: Any){
/*
binding expressionから渡される型が謎なので、もう全部チェックしちゃおうぜ的なノリ
多分全部網羅されてるはずだけど、ダメだったときはelseでデフォルトのやつを表示してる
*/
if (src is String) {
var thumbnailBitmap: Bitmap? = null
DataFromFireBase.Projects.data.forEach {
if (it.projectId == src) thumbnailBitmap = it.thumbnailBitmap
}
if (thumbnailBitmap != null) {
view.setImageBitmap(thumbnailBitmap)
} else {
view.setImageResource(R.drawable.ic_default)
}
}
else if (src is Int) {
view.setImageResource(src)
}
else if (src is BitmapDrawable){
view.setImageDrawable(src)
} else {
view.setImageResource(R.drawable.ic_default)
}
}
fragment_map.xmlについて
IDEに言われるがままRecyclerViewって書いただけなので省略
RecyclerViewの各要素のレイアウトを記述したitem_map_project_list.xml
上の方で書いたアダプタにてit.data = item
と書いていたやつとかit.vm = viewModel
とかやってたやつは、ここの<data>
タグ内で宣言してたからやらないといけないって感じですね。アダプタでデータを渡しているのでここでそのデータを参照してbinding expressionで文字を入れたり出来ます。ただ、bitmapで受け取るはずのサムネイルだけはbinding expressionでは設定できなかったのでcustomAdapterを使っています。
それから、画像を角丸にする方法が見当たらなかったのでcardviewで無理やり角丸にしています。
以上
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<variable
name="data"
type="com.rikoten.android_pamphlet.model.datamodel.map.MapProjectItemData" />
<variable
name="vm"
type="com.rikoten.android_pamphlet.viewmodel.fragment.map.MapViewModel" />
</data>
<androidx.cardview.widget.CardView
android:id="@+id/item_map_project_list_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="16dp"
app:cardElevation="5dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.cardview.widget.CardView
android:id="@+id/cardView3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:cardElevation="3dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<!--
ここではマップでの企画表示用カードのサムネイルを表示しています。
2021年10月28日現在、srcCompatにbinding expressionを用いて直接bitmapを渡すことは出来ません。
なのでBindingAdapterを使うことにしました。
BindingAdapterはMapProjectListAdapterの下の方に定義されています。
やっていることはこんな感じ。
1. 企画データのidがnullだったらデフォルトのソースをBindingAdapterにわたす。
2. idがnullでなければidをBindingAdapterにわたす。
3. BindingAdapterはそれをこのImageViewに設定する。
以上
-->
<ImageView
android:id="@+id/map_list_project_thumbnail_iv"
android:layout_width="70dp"
android:layout_height="70dp"
android:scaleType="centerCrop"
app:srcCompat="@{data.id != null ? data.id : @drawable/ic_what_is_rikoten_button_top}" />
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/map_list_group_name_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@{data.group}"
android:textSize="12sp"
app:layout_constraintBottom_toTopOf="@+id/map_list_project_name_tv"
app:layout_constraintEnd_toStartOf="@+id/map_list_bookmark_iv"
app:layout_constraintStart_toEndOf="@+id/cardView3"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/map_list_project_name_tv"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:text="@{data.title}"
android:textSize="17sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/map_list_bookmark_iv"
app:layout_constraintStart_toEndOf="@+id/cardView3"
app:layout_constraintTop_toBottomOf="@+id/map_list_group_name_tv" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/map_list_bookmark_iv"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:tint="@{data.isChecked ? @color/red : @color/gray }"
android:onClick="@{() -> vm.toggleFavorite(data)}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_bookmark"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
</layout>
一応企画データのエンティティクラスも
大したことはしてないです。
data class MapProjectItemData(
val group: String = "",
val title: String = "",
val isChecked: MutableLiveData<Boolean> = MutableLiveData(false),
val id: String?
) {
}