Всем привет! Мои посты — желание помочь в работе с какими-то элементами Android. Если вы разработчик, который еще не сформировал для себя алгоритм для построения списков — вам может оказаться полезным почитать этот материал. В основном, я бы хотел предлагать готовые решения для разработки, раскрывая в ходе повествования какие-то мысли о том, как я до них докатился к этому пришел.
В этой статье:
- формируем несколько базовых классов и интерфейсов для работы с RecyclerView и RecyclerView.Adapter
- подключим одну библиотеку из Android Jetpack (по желанию, сначала без нее)
- для еще более быстрой разработки — вариант темплейта в конце статьи ;)
Вступление
Ну что ж! Все уже забыли про ListView и благополучно пишут на RecyclerView(Rv). Те времена, когда мы реализовывали сами паттерн ViewHolder, канули в небытие. Rv предоставляет нам набор готовых классов для реализации списков и достаточно большой выбор LayoutManager'ов для их отображения. По сути, глядя на множество экранов, списком можно представить большинство из них — именно благодаря возможности для каждого элемента реализовать свой ViewHolder. Более подробно историю развития нам рассказали на Google I/O.
Но, всегда есть пара «но»!.. Стандартные ответы на Stackoverflow подсказывают общие решения, которые приводят к копипасте, особенно в месте реализации Adapter'a.
На данный момент, Rv уже три года. Инфы по нему туча, и много библиотек с готовыми решениями, но что делать, если вам не нужен весь функционал, или если вы залазите поглядеть на чужой код — и видите там Древний Ужас не то, что хотели бы видеть, или не то что вообще себе представляли? За эти три года Android наконец-таки официально принял к себе Kotlin = улучшилась читаемость кода, по Rv вышло немало интересных статей, которые в полной мере раскрывают его возможности.
Цель этой — собрать из лучших практик свой велосипед основу, каркас по работе со списками для новых приложений. Этот каркас можно дополнять логикой от приложения к приложению, используя то, что вам необходимо, и отбрасывая лишнее. Я считаю такой подход намного лучше чужой библиотеки — в своих классах вы имеете возможность разобраться с тем, как все работает, и проконтролировать кейсы в которых нуждаетесь, не завязываясь на чужом решении.
Давайте мыслить логично и с самого начала
Решать то, что должен делать компонент, будет интерфейс, а не класс, но конкретную логику реализации в конце замкнем на классе, который будет этот интерфейс имплементировать и реализовывать. Но, если получится так, что при реализации интерфейса образуется копипаста — мы можем спрятать ее за абстрактным классом, а после него — класс, который наследуется от абстрактного. Я покажу свою реализацию базовых интерфейсов, но моя цель состоит в том, чтобы разработчик просто попробовал думать в этом же направлении. Еще раз — план такой: Набор интерфейсов -> абстрактный класс, забирающий копипасту (если это нужно) -> и уже конкретный класс с уникальным кодом. Реализацию интерфейсов Вы можете выполнить по другому.
Что может делать со списком адаптер? Ответ на этот вопрос легче всего получить, когда смотришь на какой то пример. Можно заглянуть в RecyclerView.Adapter, вы найдете пару подсказок. Если же немного подумать, то можно представить примерно такие методы:
interface IBaseListAdapter<T> {
fun add(newItem: T)
fun add(newItems: ArrayList<T>?)
fun addAtPosition(pos : Int, newItem : T)
fun remove(position: Int)
fun clearAll()
}
* Перебирая проекты, я нашел несколько других методов, которые здесь опущу, например getItemByPos(position: Int), или даже subList(startIndex: Int, endIndex: Int). Повторюсь: вы сами должны смотреть, что вам нужно от проекта, и включать функции в интерфейс. Это не сложно, когда знаешь что все происходит в одном классе. Аскетизм в данном вопросе позволит избавиться от лишней логики, которая ухудшает читаемость кода, потому что конкретная реализация занимает больше строк.
Обратите внимание на дженерик T. В общем случае, адаптер работает с любым объектом списка (item), поэтому здесь нет уточнения — мы еще не выбрали наш подход. А в этой статье их будет как минимум два, первый интерфейс выглядит так:
interface IBaseListItem {
fun getLayoutId(): Int
}
Ну да, кажется логичным — мы же говорим об элементе списка, значит у каждого элемента должен быть какой-то лейаут, а сослаться на него можно с помощью layoutId. Больше ничего начинающему разработчику скорее всего не понадобится, если конечно не брать более продвинутые подходы. Если же у вас хватает опыта в разработке, можно конечно сделать делегат или обертку, но стоит ли оно того при небольшом проекте — и еще меньшем опыте разработки? Все мои ссылки куда то в ютуб очень полезны, если у вас сейчас нет времени — просто запомните их и читайте дальше, потому что здесь подход попроще — я считаю что при стандартной работе с Rv, судя по официальной документации, того что предлагается выше — не подразумевается.
Пора объединить наш IBaseListAdapter с интерфейсами, и следующий класс будет абстрактным:
abstract class SimpleListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>(), IBaseListAdapter<IBaseListItem> {
protected val items: ArrayList<IBaseListItem> = ArrayList()
override fun getItemCount() = items.size
override fun getItemViewType(position: Int) = items[position].layoutId
protected fun inflateByViewType(context: Context?, viewType: Int, parent: ViewGroup) =
LayoutInflater.from(context).inflate(viewType, parent, false)
override fun add(newItem: IBaseListItem) {
items.add(newItem)
notifyDataSetChanged()
}
override fun add(newItems: ArrayList<IBaseListItem>?) {
for (newItem in newItems ?: return) {
items.add(newItem)
notifyDataSetChanged()
}
}
override fun addAtPosition(pos: Int, newItem: IBaseListItem) {
items.add(pos, newItem)
notifyDataSetChanged()
}
override fun clearAll() {
items.clear()
notifyDataSetChanged()
}
override fun remove(position: Int) {
items.removeAt(position)
notifyDataSetChanged()
}
}
*Примечание: Обратите внимание на переопределенную функцию getItemViewType(position: Int). Нам нужен некий интовый ключ, по которому Rv поймет, какой ViewHolder нам подходит. Для этого отлично пригодится val layoutId у нашей item, т.к. Андроид каждый раз услуживо делает id лейаутов уникальными, и все значения больше нуля — этим мы и воспользуемся далее, «надувая» itemView для наших вьюхолдеров в методе inflateByViewType() (следующая строка).
Создаем список
Возьмем для примера экран настроек. Андроид предлагает нам свой вариант, но что если по дизайну понадобится что-то более изощренное? Я предпочитаю наполнять этот экран как список. Тут будет приведен такой кейс:
Мы видим два разных элемента списка, значит SimpleListAdapter и Rv тут прекрасно подойдут!
Приступим! Можно начать с верстки лейаутов для item'ов:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="56dp">
<TextView
android:id="@+id/tv_info_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="28dp"
android:textColor="@color/black"
android:textSize="20sp"
tools:text="Balance" />
<TextView
android:id="@+id/tv_info_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginEnd="48dp"
tools:text="1000 $" />
</FrameLayout>
<!---->
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="56dp">
<TextView
android:id="@+id/tv_switch_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="28dp"
android:textColor="@color/black"
android:textSize="20sp"
tools:text="Send notifications" />
<Switch
android:id="@+id/tv_switch_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginEnd="48dp"
tools:checked="true" />
</FrameLayout>
Затем, определяем сами классы, внутрь которых мы хотим передать значения, которые взаимодействуют со списком: первый — это заголовок и какое-либо значение, пришедшее извне (у нас будет заглушка, о запросах в другой раз), второй — это заголовок и boolean переменная, по которому мы должны выполнить действие. Чтобы различить Switch элементы, подойдут id сущностей с сервера, если же их нет — мы можем создать их сами при инициализации.
class InfoItem(val title: String, val value: String): IBaseListItem {
override val layoutId = R.layout.item_info
}
class SwitchItem(
val id: Int,
val title: String,
val actionOnReceive: (itemId: Int, userChoice: Boolean) -> Unit
) : IBaseListItem {
override val layoutId = R.layout.item_switch
}
В простой реализации каждому элементу также понадобится ViewHolder:
class InfoViewHolder.kt(view: View) : RecyclerView.ViewHolder(view) {
val tvTitle = view.tv_info_title
val tvValue = view.tv_info_value
}
class SwitchViewHolder.kt(view: View) : RecyclerView.ViewHolder(view) {
val tvTitle = view.tv_switch_title
val tvValue = view.tv_switch_value
}
Ну и самая интересная часть — конкретная реализация SimpleListAdapter'a:
class SettingsListAdapter : SimpleListAdapter() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val context = parent.context
return when (viewType) {
R.layout.item_info -> InfoHolder(inflateByViewType(context, viewType, parent))
R.layout.item_switch -> SwitchHolder(inflateByViewType(context, viewType, parent))
else -> throw IllegalStateException("There is no match with current layoutId")
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is InfoHolder -> {
val infoItem = items[position] as InfoItem
holder.tvTitle.text = infoItem.title
holder.tvValue.text = infoItem.value
}
is SwitchHolder -> {
val switchItem = items[position] as SwitchItem
holder.tvTitle.text = switchItem.title
holder.tvValue.setOnCheckedChangeListener { _, isChecked ->
switchItem.actionOnReceive.invoke(switchItem.id, isChecked)
}
}
else -> throw IllegalStateException("There is no match with current holder instance")
}
}
}
*Примечание: Не забывайте про то, что под капотом метода inflateByViewType(context, viewType, parent): viewType = layoutId.
Все составляющие готовы! Теперь, остается код Активити и можно запускать программу:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<android.support.v7.widget.RecyclerView
android:id="@+id/rView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
val adapter = SettingsListAdapter()
rView.layoutManager = LinearLayoutManager(this)
rView.adapter = adapter
adapter.add(InfoItem("User Name", "Leo Allford"))
adapter.add(InfoItem("Balance", "350 $"))
adapter.add(InfoItem("Tariff", "Business"))
adapter.add(SwitchItem(1, "Send Notifications") { itemId, userChoice -> onCheck(itemId, userChoice) })
adapter.add(SwitchItem(2, "Send News on Email") { itemId, userChoice -> onCheck(itemId, userChoice) })
}
private fun onCheck(itemId: Int, userChoice: Boolean) {
when (itemId) {
1 -> Toast.makeText(this, "Notification now set as $userChoice", Toast.LENGTH_SHORT).show()
2 -> Toast.makeText(this, "Send news now set as $userChoice", Toast.LENGTH_SHORT).show()
}
}
}
В итоге, при построении списка, вся работа сводится к следующему:
1. Вычисление количества разных лейаутов для итемов
2. Подобрать им названия. Я пользуюсь правилом: SomethingItem.kt, item_something.xml, SomethingViewHolder.kt
3. Пишем к этим классам адаптер. В принципе, если вы не претендуете на оптимизацию, то хватит одного общего адаптера. Но в больших проектах я бы все же сделал несколько, по экранам, потому что в первом случае неизбежно разрастается метод onBindViewHolder() (страдает читаемость кода) в вашем адаптере (в нашем случае это SettingsListAdapter) + программе придется каждый раз, для каждого итема, пробегаться по этому методу + по методу onCreateViewHolder()
4. Запускаем код и радуемся!
JetPack
До этого момента мы применяли стандартный подход привязки данных из Item.kt — к нашему item_layout.xml. Но мы можем унифицировать метод onBindViewHolder(), оставить его минимальным, а логику перенести в Item и лейаут.
Зайдем на официальную страницу Android JetPack:
Обратим внимание на первую вкладку в разделе Architecture. Android Databinding — очень обширная тема, я бы хотел поговорить об ней более подробно в других статьях, но сейчас воспользуемся только в рамках текущей — мы сделаем нашу Item.kt — variable для item.xml (или можете назвать ее вьюмоделью для лейаута).
На момент написания статьи, Databinding можно было подключить вот так:
android {
compileSdkVersion 27
defaultConfig {...}
buildTypes {...}
dataBinding {
enabled = true
}
dependencies {
kapt "com.android.databinding:compiler:3.1.3"
//...
}
}
Пройдемся заново по базовым классам. Интерфейс для итема дополняет предыдущий:
interface IBaseItemVm: IBaseListItem {
val brVariableId: Int
}
Также, мы расширим наш ViewHolder, потому связались с датабайдингом. Будем передавать в него ViewDataBinding, после чего благополучно забудем о создании лейаута и привязке данных
class VmViewHolder(val binding: ViewDataBinding) : RecyclerView.ViewHolder(binding.root)
Такой же подход используется здесь, но на котлине это выглядит намного короче, не правда ли? =)
class VmListAdapter : RecyclerView.Adapter<VmViewHolder>(), IBaseListAdapter<IBaseItemVm> {
private var mItems = ArrayList<IBaseItemVm>()
override fun getItemCount() = mItems.size
override fun getItemViewType(position: Int) = mItems[position].layoutId
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VmViewHolder {
val inflater = LayoutInflater.from(parent.context)
val viewDataBinding = DataBindingUtil.inflate<ViewDataBinding>(inflater!!, viewType, parent, false)
return VmViewHolder(viewDataBinding)
}
override fun onBindViewHolder(holder: VmViewHolder, position: Int) {
holder.binding.setVariable(mItems[position].brVariableId, mItems[position])
holder.binding.executePendingBindings()
}
override fun add(newItem: IBaseItemVm) {
mItems.add(newItem)
notifyItemInserted(mItems.lastIndex)
}
override fun add(newItems: ArrayList<IBaseItemVm>?) {
val oldSize = mItems.size
mItems.addAll(newItems!!)
notifyItemRangeInserted(oldSize, newItems.size)
}
override fun clearAll() {
mItems.clear()
notifyDataSetChanged()
}
override fun getItemId(position: Int): Long {
val pos = mItems.size - position
return super.getItemId(pos)
}
override fun addAtPosition(pos: Int, newItem: IBaseItemVm) {
mItems.add(pos, newItem)
notifyItemInserted(pos)
}
override fun remove(position: Int) {
mItems.removeAt(position)
notifyItemRemoved(position)
}
}
Обратите внимание в целом на методы onCreateViewHolder(), onBindViewHolder(). Задумка в том, чтобы они больше не разрастались. Итого, вы получаете один адаптер для любого экрана, с любыми элементами списка.
Наши items:
class InfoItem(val title: String, val value: String) : IBaseItemVm {
override val brVariableId = BR.vmInfo
override val layoutId = R.layout.item_info
}
//
class SwitchItem(
val id: Int,
val title: String,
private val actionOnReceive: (itemId: Int, userChoice: Boolean) -> Unit
) : IBaseItemVm {
override val brVariableId = BR.vmSwitch
override val layoutId = R.layout.item_switch
val listener = CompoundButton.OnCheckedChangeListener { _, isChecked ->
actionOnReceive.invoke(id, isChecked) }
}
Здесь становится видно, куда делась логика метода onBindViewHolder(). Ее взял на себя Android Databinding — теперь любой наш лейаут подкреплен своей вьюмоделью, и она спокойно обработает всю логику нажатий, анимаций, запросов и прочего. Что вы сами придумаете. В этом хорошо помогут Binding Adapters — позволив связать вью с данными любого рода. также, связь возможно улучшить благодаря двустороннему датабайдингу. Наверное он промелькнет в какой-нибудь из следующих статей, в данном примере можно сделать все проще. Нам достаточно одного байндинг адаптера:
@BindingAdapter("switchListener")
fun setSwitchListener(sw: Switch, listener: CompoundButton.OnCheckedChangeListener) {
sw.setOnCheckedChangeListener(listener)
}
После этого, связываем наши значения переменных с нашими 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>
<import type="com.lfkekpoint.adapters.adapters.presentation.modules.bindableItemsSettings.InfoItem" />
<variable
name="vmInfo"
type="InfoItem" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="56dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="28dp"
android:text="@{vmInfo.title}"
android:textColor="@color/black"
android:textSize="20sp"
tools:text="Balance" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginEnd="48dp"
android:text="@{vmInfo.value}"
tools:text="1000 $" />
</FrameLayout>
</layout>
<!---->
<?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">
<data>
<import type="com.lfkekpoint.adapters.adapters.presentation.modules.bindableItemsSettings.SwitchItem" />
<variable
name="vmSwitch"
type="SwitchItem" />
</data>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="56dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="28dp"
android:text="@{vmSwitch.title}"
android:textColor="@color/black"
android:textSize="20sp"
tools:text="Send notifications" />
<Switch
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|end"
android:layout_marginEnd="48dp"
app:switchListener="@{vmSwitch.listener}"
tools:checked="true" />
</FrameLayout>
</layout>
app:switchListener="@{vmSwitch.listener}" — в этой строке мы воспользовались нашим BindingAdapter'ом
*Примечание: Вполне по справедливым причинам, кое-кому может показаться, что мы пишем много больше кода в xml — но это вопрос знаний библиотеки Android Databinding. Она дополняет лейаут, быстро читается и в принципе по большей части убирает именно бойлерплейт. Я думаю, Google собирается хорошо развить эту библиотеку, раз она находится первой во вкладке Architecture, в Android Jetpack. Попробуйте в паре проектов сменить MVP на MVVM — и многие могут быть приятно удивлены.
Ну что ж!.. А, код в SettingsActivity:
class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
val adapter = BaseVmListAdapter()
rView.layoutManager = LinearLayoutManager(this)
rView.adapter = adapter
adapter.add(InfoItem("User Name", "Leo Allford"))
adapter.add(InfoItem("Balance", "350 $"))
adapter.add(InfoItem("Tariff", "Business"))
adapter.add(SwitchItem(1, "Send Notifications") { itemId, userChoice -> onCheck(itemId, userChoice) })
adapter.add(SwitchItem(2, "Send News on Email") { itemId, userChoice -> onCheck(itemId, userChoice) })
}
private fun onCheck(itemId: Int, userChoice: Boolean) {
when (itemId) {
1 -> Toast.makeText(this, "Notification now set as $userChoice", Toast.LENGTH_SHORT).show()
2 -> Toast.makeText(this, "Send news now set as $userChoice", Toast.LENGTH_SHORT).show()
}
}
}
Итог
Мы получили алгоритм построения списков и инструменты для работы с ними. В моем случае (почти всегда использую Databinding) вся подготовка сводится к инициализации базовых классов по папкам, верстке итемов в .xml а затем привязке к переменным в .kt.
Ускоряем разработку
Для более быстрой работы, я воспользовался шаблонами от Apache для Android Studio — и написал свои темплейты с небольшой демонстрацией, как это все работает. Очень надеюсь, что кому-то пригодится. Обратите внимание, что при работе вызывать темплейт нужно из корневой папки проекта — это сделано потому что параметр applicationId проекта может вам наврать, если вы поменяли его в Gradle. А вот packageName так просто не проведешь, чем я и воспользовался. Доступным языком про шаблонизацию можно почитать по ссылкам ниже
Список литературы/медиа
1. Modern Android development: Android Jetpack, Kotlin, and more (Google I/O 2018, 40 m.) — краткий гайд на то, что сегодня в моде, отсюда также в общих чертах станет понятно как развивался RecyclerView;
2. Droidcon NYC 2016 — Radical RecyclerView, 36 m. — подробный доклад о RecyclerView от Lisa Wray;
3. Create a List with RecyclerView — официальная документация
5. Android IDE Template Format, Тотальная шаблонизация, мануал FreeMarker — удобный подход, который в рамках этой статьи поможет быстро создавать нужные файлы по работе со списками
6. Код к статье(там немного другие названия классов, будьте внимательны), темплейты для работы и видео, как работать с темплейтами
Автор: L_F_Kek