
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
