RecyclerView — основной UI элемент практически любого приложения. Написание адаптеров и ViewHolder'ов зачастую слишком рутинная работа и содержит достаточно boilerplate кода. В этой статье я хочу показать как с использованием DataBinding и паттерна MVVM можно написать абстрактный адаптер и напрочь забыть про ViewHolder'ы, inflate, ручной биндинг и прочую рутину.
ViewHolder
Мы все привыкли писать отдельный ViewHolder под каждый тип ячеек в таблице для хранения ссылок на отдельные вьюшки и связывания данных.
Можно сказать что DataBinding генерирует на лету тот код, что вы обычно пишите в ViewHolder'ах, поэтому надобность в них отпадает и мы легко можем использовать одну реализацию, хранящую в себе объект готового биндинга:
abstract class ViewModelAdapter : RecyclerView.Adapter<ViewModelAdapter.ViewHolder>() {
class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
val binding = DataBindingUtil.bind<ViewDataBinding>(view)
}
}
ViewDataBinding это базовый абстрактный класс для всех сгенерированных классов DataBinding'а и хоть мы и передаем его параметром шаблона для метода bind, DataBindingUtil сам поймет какой layout мы используем и какую реализацию в итоге использовать.
ViewModelAdapter
Разобравшись с ViewHolder'ом надо определиться чего мы хотим от нашего базового адаптера в итоге. Все, что мне требуется от адаптера в пределах MVVM архитектуры — отдать список объектов (ViewModel'ей), сказать какую разметку я хочу использовать для данных в этом списке классов и совершенно не беспокоиться о необходимой для этого логике.
Логику привязки данных на себя берет DataBinding, но это уже совершенно другая статья, коих в интернете уже достаточно.
Напишем логику для конфигурации нашего адаптера:
abstract class ViewModelAdapter : RecyclerView.Adapter<ViewModelAdapter.ViewHolder>() {
data class CellInfo(val layoutId: Int, val bindingId: Int)
protected val items = LinkedList<Any>()
private val cellMap = Hashtable<Class<out Any>, CellInfo>()
protected fun cell(clazz: Class<out Any>, @LayoutRes layoutId: Int, bindingId: Int) {
cellMap[clazz] = CellInfo(layoutId, bindingId)
}
protected fun getCellInfo(viewModel: Any): CellInfo {
cellMap.entries
.filter { it.key == viewModel.javaClass }
.first { return it.value }
throw Exception("Cell info for class ${viewModel.javaClass.name} not found.")
}
}
Для каждого класса объектов таблицы будем хранить пару layoutId и bindingId.
- layoutId — как понятно из имени и аннотации @LayoutRes это соответствующая разметка ячейки.
- bindingId — это сгенерированный идентификатор переменной, используемый в соответствующей разметке. Он нам понадобится для того, чтобы забиндить объект таблицы в написанный ранее ViewHolder, а точнее в ViewDataBinding.
Остается лишь реализовать абстрактные функции RecyclerView.Adapter:
abstract class ViewModelAdapter : RecyclerView.Adapter<ViewModelAdapter.ViewHolder>() {
override fun getItemCount(): Int = items.size
override fun getItemViewType(position: Int): Int {
return getCellInfo(items[position]).layoutId
}
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent?.context)
val view = inflater.inflate(viewType, parent, false)
return ViewHolder(view)
}
override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
if (holder != null) {
val cellInfo = getCellInfo(items[position])
if (cellInfo.bindingId != 0)
holder.binding.setVariable(cellInfo.bindingId, items[position])
}
}
}
- getItemViewType — так как layoutId уникален для разных ячеек мы с легкостью можем использовать его как viewType.
- onCreateViewHolder — не забываем что viewType это наш layoutId.
- onBindViewHolder — все что требуется для привязки данных объекта к разметке — сообщить DataBinding'у о том, что в данной ячейке теперь новый объект, всю остальную логику он возьмет на себя.
На этом вся основная логика ViewModelAdapter описана, однако остается одна проблема — обработка кликов по ячейкам. Обычно эту логику описывают в Activity, но я не любитель транслировать логику вверх по иерархии, если без этого ну никак не обойтись, поэтому реализую ее прямо в адаптере, но вы можете реализовывать ее там где вам удобно.
Для реализации обработки кликов добавим в ViewModelAdapter такое понятие как sharedObject, объект который будет биндится на все ячейки таблицы (не обязательно, если в разметке не найдет variable с данным bindingID ничего не упадет).
abstract class ViewModelAdapter : RecyclerView.Adapter<ViewModelAdapter.ViewHolder>() {
private val sharedObjects = Hashtable<Int, Any>()
protected fun sharedObject(sharedObject: Any, bindingId: Int) {
sharedObjects[bindingId] = sharedObject
}
override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int): ViewHolder {
val inflater = LayoutInflater.from(parent?.context)
val view = inflater.inflate(viewType, parent, false)
val viewHolder = ViewHolder(view)
sharedObjects.forEach { viewHolder.binding.setVariable(it.key, it.value) }
return viewHolder
}
}
Теперь рассмотрим как это все в итоге работает:
Как пример я реализовал адаптер для бокового меню (используйте NavigationView из стандартной библиотеки если у вас нет необходимости отойти от Material Design).
object NavigationAdapter : ViewModelAdapter() {
init {
cell(NavigationHeaderViewModel::class.java, R.layout.cell_navigation_header, BR.vm)
cell(NavigationItemViewModel::class.java, R.layout.cell_navigation_item, BR.vm)
cell(NavigationSubheaderViewModel::class.java, R.layout.cell_navigation_subheader, BR.vm)
sharedObject(this, BR.adapter)
}
override fun reload(refreshLayout: SwipeRefreshLayout?) {
items.clear()
items.add(NavigationHeaderViewModel)
items.add(NavigationItemViewModel(R.drawable.ic_inbox_black_24dp, "Inbox"))
items.add(NavigationItemViewModel(R.drawable.ic_star_black_24dp, "Starred"))
items.add(NavigationItemViewModel(R.drawable.ic_send_black_24dp, "Sent mail"))
items.add(NavigationItemViewModel(R.drawable.ic_drafts_black_24dp, "Drafts"))
items.add(NavigationSubheaderViewModel("Subheader"))
items.add(NavigationItemViewModel(R.drawable.ic_mail_black_24dp, "All mail"))
items.add(NavigationItemViewModel(R.drawable.ic_delete_black_24dp, "Trash"))
items.add(NavigationItemViewModel(R.drawable.ic_report_black_24dp, "Spam"))
notifyDataSetChanged()
}
fun itemSelected(view: View, model: NavigationItemViewModel) {
Toast.makeText(view.context, "${model.title} selected!", Toast.LENGTH_SHORT).show()
}
}
И как пример layout: cell_navigation_item.xml
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="vm"
type="com.github.akvast.mvvm.ui.vm.NavigationItemViewModel" />
<variable
name="adapter"
type="com.github.akvast.mvvm.ui.adapter.NavigationAdapter" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:onClick="@{v -> adapter.itemSelected(v, vm)}">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical"
android:layout_marginLeft="16dp"
android:src="@{vm.icon}"
android:tint="@{@color/grey_600}" />
<TextView
style="@style/TextAppearance.AppCompat.Body2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="48dp"
android:paddingBottom="12dp"
android:paddingLeft="72dp"
android:paddingRight="16dp"
android:paddingTop="12dp"
android:text="@{vm.title}"
tools:text="Item title" />
</FrameLayout>
</layout>
Как видите все достаточно просто, нет никакой лишней логики. Мы можем объявлять сколько угодно типов ячеек вызовом 1 функции. Мы можем позабыть о ручном связывании данных для UI.
Данный адаптер успешно проходит боевые испытания на протяжении полугода в нескольких крупных проектах.
С удовольствием отвечу на ваши вопросы в комментариях.
Полезные ссылки
→ Полный код и example проект на GitHub
→ ViewModelAdapter, написанный на Java
→ Официальная документация по DataBinding
→ Настройка использования DataBinding и других библиотек в Kotlin