リアクティブなRecyclerView
(リアクティブ感を出すために、FireStoreに直接インサートしたデモです)
本記事のポイント
- FireStoreのデータをRxKotlinを使ってViewに反映
- ViewModelからViewへの単方向DataBinding
さらっと出てくるけど説明しないもの
- Dagger
- RecyclerViewの基本的な使い方
Utility
RxkotlinとLiveDataの接続を容易にするためにextentionを定義します。
import androidx.lifecycle.LiveData
import androidx.lifecycle.LiveDataReactiveStreams
import org.reactivestreams.Publisher
fun <T> Publisher<T>.toLiveData() = LiveDataReactiveStreams.fromPublisher(this) as LiveData<T>
画面のレイアウト
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".presentation.main.TodoListFragment">
<data>
<variable
name="fragment"
type="sample.presentation.main.TodoListFragment" />
<variable
name="viewModel"
type="sample.presentation.main.TodoListViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/todo_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#ffcccccc"
android:clipToPadding="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="32dp"
android:layout_marginBottom="32dp"
android:clickable="true"
android:focusable="true"
android:tint="@android:color/holo_red_dark"
android:onClick="@{() -> fragment.addButtonClicked()}"
app:backgroundTint="@android:color/holo_red_light"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:srcCompat="@android:drawable/ic_input_add" />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
dataBindingで状態の変化に強いviewを作る
viewModelで公開しているLiveDataをxmlからdatabindingすることで、LiveDataのデータが変わると自動的にレイアウトも変わるようになる。
RxKotlin -> LiveData -> layoutとstreamが繋がり幸せ度が高い。
ボタンのクリックイベントはdatabindingでbinding先のメソッドを呼び出すようにしている。かつて、クリックイベントを直接メソッドに紐づける場合は、butterknife等でやられていた、今は公式のフレームワークで可能。
Activityのlayoutは何も書いていないので省略。
activity
import android.os.Bundle
import androidx.databinding.DataBindingUtil
import sample.R
import sample.databinding.ActivityMainBinding
import sample.presentation.NavigationController
import dagger.android.support.DaggerAppCompatActivity
import javax.inject.Inject
class MainActivity : DaggerAppCompatActivity() {
@Inject
lateinit var navigationController: NavigationController
private val binding: ActivityMainBinding by lazy {
DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding
navigationController.navigateToTodoList()
}
}
databindingを利用する場合はDataBindingUtilを利用する。ただ、bindingオブジェクトはclassの至る所で触るのでlazyで定義すると使いやすい。
fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import sample.databinding.FragmentTodoListBinding
import sample.di.ViewModelFactory
import sample.presentation.NavigationController
import dagger.android.support.DaggerFragment
import javax.inject.Inject
class TodoListFragment : DaggerFragment() {
companion object {
fun newInstance() = TodoListFragment()
}
@Inject lateinit var factory: ViewModelFactory
@Inject lateinit var navigationController: NavigationController
private val binding: FragmentTodoListBinding by lazy {
FragmentTodoListBinding.inflate(requireActivity().layoutInflater).apply {
viewModel = fragmentViewModel
fragment = this@TodoListFragment
}
}
private val fragmentViewModel: TodoListViewModel by lazy {
ViewModelProviders.of(this, this.factory).get(TodoListViewModel::class.java)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return this.binding.root
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
this.binding.todoList.setHasFixedSize(true)
this.binding.todoList.adapter = TodoRecyclerViewAdapter(this.viewLifecycleOwner, this.fragmentViewModel)
this.binding.todoList.layoutManager = LinearLayoutManager(context)
}
/**
* フロートボタンのイベントハンドラ。
* layoutのxmlからイベントバインディングしている。
*/
fun addButtonClicked() {
val fragment = InputDialogFragment.newInstance()
fragment.show(requireFragmentManager(), "tag")
}
}
floatingActionButtonのイベントハンドラ先については、今回は関係ないので省略。
viewModel
import androidx.lifecycle.ViewModel
import sample.repository.TodoRepository
import sample.util.ext.toLiveData
import io.github.droidkaigi.confsched2018.util.rx.SchedulerProvider
import javax.inject.Inject
class TodoListViewModel @Inject constructor(
private val todoRepository: TodoRepository,
schedulerProvider: SchedulerProvider
): ViewModel() {
val todoData = this.todoRepository.getTodoList().subscribeOn(schedulerProvider.io()).toLiveData()
}
RepositoryのTodoListから受け取ったものをLiveDataに変換して外部に公開している。
これをlayoutでdatabindingしている。
Repository
import sample.dataStore.TodoDataStore
import sample.model.Todo
import io.reactivex.Flowable
import io.reactivex.Single
import javax.inject.Inject
class TodoRepositoryImpl @Inject constructor(private val todoDataStore: TodoDataStore): TodoRepository {
override fun getTodoList(): Flowable<List<Todo>> {
return this.todoDataStore.getTodoDataList().map { it.sortedBy { it.registerDate } }
}
override fun addTodo(todo: Todo): Single<Unit> {
return this.todoDataStore.addTodoData(todo)
}
}
dataStoreから上がってきたものをソートして上に返しているだけ。
dataStore
import com.google.firebase.Timestamp
import com.google.firebase.firestore.FirebaseFirestore
import sample.model.Todo
import io.reactivex.BackpressureStrategy
import io.reactivex.Flowable
import io.reactivex.Observable
import io.reactivex.Single
import timber.log.Timber
import java.time.Instant
import java.time.OffsetDateTime
import java.time.ZoneId
class TodoDataStoreImpl: TodoDataStore {
private val collectionPath = "todo"
data class TodoForFireStore(val title: String,
val message: String,
val done: Boolean,
val registerDate: Timestamp)
override fun addTodoData(todo: Todo): Single<Unit> {
return Single.create<Unit> { observer ->
val todoForFireStore = TodoForFireStore(
title = todo.title,
message = todo.message,
done = todo.done,
registerDate = Timestamp(todo.registerDate.toEpochSecond(), todo.registerDate.nano)
)
val db = FirebaseFirestore.getInstance()
db.collection(this.collectionPath).add(todoForFireStore).addOnSuccessListener {reference ->
Timber.d(reference.toString())
observer.onSuccess(Unit)
}.addOnFailureListener {
Timber.d(it.toString())
observer.onError(it)
}
}
}
override fun getTodoDataList(): Flowable<List<Todo>> {
return Observable.create<List<Todo>> { observer ->
val db = FirebaseFirestore.getInstance()
db.collection(this.collectionPath).addSnapshotListener { snapshot, exception ->
// もしエラーが起きてたら
if (exception != null) {
Timber.d(exception.toString())
observer.onError(exception)
return@addSnapshotListener
}
if (snapshot == null || snapshot.isEmpty) {
Timber.d("data is empty")
observer.onNext(listOf())
return@addSnapshotListener
}
observer.onNext(snapshot.map{
val title = it.get("title") as String
val message = it.get("message") as String
val done = it.get("done") as Boolean
val registeredTimestamp = it.get("registerDate") as Timestamp
val registerDate = OffsetDateTime.ofInstant(
Instant.ofEpochMilli(registeredTimestamp.seconds * 1000),
ZoneId.systemDefault()
)
Todo(
title = title,
message = message,
done = done,
registerDate = registerDate
)
})
}
return@create
}.toFlowable(BackpressureStrategy.ERROR)
}
}
FireStoreを扱うdataStore。裏側の実装がfireStoreであることは、ここで完全に隠蔽し、ここから上のレイヤ(RepositoryやviewModel)には、裏側の実装を意識させない。
recyclerViewAdapter
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import sample.databinding.ItemBinding
import sample.model.Todo
class TodoRecyclerViewAdapter(lifeCycleOwner: LifecycleOwner, private val viewModel: TodoListViewModel):
RecyclerView.Adapter<TodoRecyclerViewAdapter.BindingHolder>() {
// Adapterが知っている一番最後のlist
private var oldList: List<Todo> = listOf()
// 初期化時にviewModelを購読して、変更があった場合、RecyclerViewに通知する
init {
this.viewModel.todoData.observe(lifeCycleOwner, Observer {
this.updateList(it)
})
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TodoRecyclerViewAdapter.BindingHolder {
val binding = ItemBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun getItemCount(): Int = this.viewModel.todoData.value?.size ?: 0
override fun onBindViewHolder(holder: BindingHolder, position: Int) {
holder.binding.todo = this.oldList[position]
}
private fun updateList(newList: List<Todo>) {
val diffResult = DiffUtil.calculateDiff(TodoRecyclerViewAdapter.TodoDiffUtilImpl(this.oldList, newList))
diffResult.dispatchUpdatesTo(this)
this.oldList = newList
}
data class BindingHolder(val binding: ItemBinding) : RecyclerView.ViewHolder(binding.root)
class TodoDiffUtilImpl(private val oldList: List<Todo>, private val newList: List<Todo>): DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
= oldList[oldItemPosition].registerDate == newList[newItemPosition].registerDate
override fun getOldListSize(): Int = oldList.size
override fun getNewListSize(): Int = newList.size
override fun getChangePayload(oldItemPosition: Int, newItemPosition: Int): Any? {
return super.getChangePayload(oldItemPosition, newItemPosition)
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean
= oldList[oldItemPosition] == newList[newItemPosition]
}
}
AdapterではtodoListに更新があった場合、変更を検知してリストを更新しなければならない。そのために、DiffUtilなるものが公式から提供されている。
二つのリストに違いがあった場合に、RecyclerView用の変更イベントを投げてくれる。
DiffUtilを継承したクラスで、リストのDiffとは何かを定義する。
最後に
RecyclerViewAdapterの構成を非常に悩んだ。
今でもoldList
をAdapterで保持するのが正解かどうかわからない・・・
viewModelに持たせた方が良いような・・・。
でもoldList
が必要なのはAdapterが原因な訳で、viewModelが他のクラスに影響されるのもどうなのかなーとか。
(ほとんど処理が無いアプリなので当たり前ですが)かなりスッキリかけたので、とりあえずこれで良いかな・・・