Долго я искал в сети способ сделать так, чтобы данные при построении списка RecyclerView не загружались целиком, а подгружались по мере его пролистывания пользователем. Несколько совершенно разных решений находил на StackOverflow. Пробовал применить - работало, но каждый раз, как-то криво и не надежно. После нескольких месяцев работы над проектом в режиме "Когда все дела сделаны и дети слезли с шеи", я наконец достиг, как мне кажется, идеального решения, чем и хочу поделиться в этой статье.
Задача
Мне нужно было отобразить для пользователя моего приложения список клиентов, консультаций и расходов из Базы Данных приложения в разных фрагментах. Один грамотный программист Баз Данных, по совместительству - мой шурин, объяснил мне что лучше отображать не все данные сразу, а только те, которые видны пользователю и реализовать возможность подгружать данные из БД по мере необходимости.
![Скриншот главного экрана моего приложения Скриншот главного экрана моего приложения](https://habrastorage.org/getpro/habr/upload_files/24e/5fc/512/24e5fc512c5036b5c2f13c32d707a94d.png)
Решение
1. Настройка RecyclerView для отображения списка
В нескольких местах в сети прочел, что компонент ListView уже морально устарел. Подробно описывать работу RecyclerView не буду, дам лишь несколько кусков кода в качестве примера с короткими комментариями. Для работы со списком необходимы:
Единый макет для элементов спика (rc_timetable.xml).
Компонент RecyclerView в макете Активности (androidx.recyclerview.widget.RecyclerView).
Адаптер, отвечающий за отображение элементов списка (RecyclerView.Adapter)
Функция инициализации адаптера (fun initAdapter).
Функция заполнения списка (fun fillAdapter).
1.1 Макет элементов списка
Макет элемента списка ничем не отличается от макетов экранов приложения. Я использую ConstraintLayout, в котором размещаю все, что мне необходимо показать пользователю в качестве отдельного элемента. Не забываю указать родительскому контейнеру layout_height = wrap_content.
![Макет элемента спика Макет элемента спика](https://habrastorage.org/getpro/habr/upload_files/fc0/030/a4e/fc0030a4e4e9723cd245999d74f496a7.png)
1.2 RecyclerView в макете Активности
Про добавление компонета RecyclerView мне сказать особо нечего. В макете Активности пишем его код или вставляем при помощи Визуального Дизайнера.
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rcView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
1.3 Адаптер
С адаптером дела обстоят несколько сложнее. Он должен быть описан при помощи двух классов, наследующихся от RecyclerView.Adapter и RecyclerView.ViewHolder соответственно. Первый, как я понял, отвечает за работу всего списка. А второй - создается для каждого элемента в отдельности и отрисовывает его.
Покажу на примере адаптера, отвечающего за отображение списка консультаций из календаря. В качестве параметра при создании объекта класса AdapterTimetable я передаю данные для построения списка в форме ArrayList<ListMeetings>
class ListMeetings {
var clientName = ""
var idClient = 0
var start : Long = 0
var end : Long = 0
lateinit var uri: Uri
var form = 0
var format = 0
var isParentsExist = false
// расчитывается из даты
var day = 0
var month = 0
var dayOfWeek = 0
var startTime = ""
var endTime = ""
var duration = 0
}
Сам код адаптера с некоторыми сокращениями выглядит следующим образом:
class AdapterTimetable(
private var listItems: ArrayList<ListMeetings>
) :
RecyclerView.Adapter<AdapterTimetable.MyHolder>() {
private lateinit var el: RcTimetableBinding
class MyHolder(
itemView: View,
private val el: RcTimetableBinding,
) : RecyclerView.ViewHolder(itemView) {
fun drawItem(item: ListMeetings) {
...
// указываем время Встречи
el.tvStartTime.text = item.startTime
// указываем название Услуги
el.tvService.text = "Консультация"
// указываем тему Встречи
el.tvTopic.text = "Тема Встречи"
...
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MyHolder {
val inflater = LayoutInflater.from(parent.context)
el = RcTimetableBinding.inflate(inflater,parent,false)
return MyHolder(el.root, context, el)
}
override fun onBindViewHolder(holder: MyHolder, position: Int) {
// рисуем элемент списка
holder.drawItem(listItems[position])
}
override fun getItemCount(): Int {
return listItems.size
}
fun updateAdapter(items: ArrayList<ListMeetings>){
// обновляем список
listItems.clear()
listItems.addAll(items)
notifyDataSetChanged()
}
fun removeItem(pos: Int, calManager: CalManager){
// удаляем элемент из списка
calManager.deleteMeeting(listItems[pos].uri) // удаляем встречу из календаря
listItems.removeAt(pos) // удаляем элемент из списка с позиции pos
notifyItemRangeChanged(0,listItems.size) // указываем адаптеру новый диапазон элементов
notifyItemRemoved(pos) // указываем адаптеру, что один элемент удалился
}
}
Поясню вкратце вышеприведенный код.
Для обращения к компонентам макета из кода программы я использую некий viewBinding. Эксперты в сети советуют его вместо findViewById. Мне он понравился. Удобно обращаться к компонентам макета через одну переменную (в моей программе - это private val el: RcTimetableBinding). Подключается viewBinding в build.Gradle (Module) следующим образом:
android {
...
buildFeatures {
viewBinding = true
}
}
В классе MyHolder единственная функция drawItem заполняет содержимым компоненты макета каждого элемента списка. В качестве параметра она получает данные типа ListMeetings.
В классе адаптера переопределяются три функции: onCreateViewHolder, onBindViewHolder и getItemCount. Первая "раздувает" макет элемента списка (inflate) при его создании. Вторая - наполняет элемент содержимым. А третья - возвращает количество элементов списка.
Также в адаптере должны присутствовать еще две функции: updateAdapter и removeItem. Первая обновляет содержимое списка, а вторая удаляет из него один элемент.
Надеюсь, что мои столь краткие комментарии достаточны, чтобы понять, как работает вышеприведенный код. Подробнее почитать о том, как работает RecyclerView вы можете, например, на сайте Александра Климова: http://developer.alexanderklimov.ru/android/views/recyclerview-kot.php
1.4 Функция инициализации адаптера
private fun initAdapter(){
el.rcView.layoutManager = LinearLayoutManager(requireContext())
adapter = AdapterTimetable(ArrayList())
el.rcView.adapter = adapter
}
Адаптер используем в активности или фрагменте, который связан с макетом, содержащим RecyclerView. Указываем, что для отображения элементов списка будет использоваться LinearLayoutManager (элементы будут располагаться вертикально один под другим). Создаем adapter и присваеваем его нашему компоненту Recyclerview (rcView).
1.5 Функция заполнения списка
fun fillAdapter(){
val list = calManager.readMeetingsList()
if (list.isNotEmpty()) adapter.updateAdapter(list)
}
Здесь пока все просто - загружаем данные из Базы Данных (calManager.readMeetingsList) и обновляем список новыми данными (adapter.updateAdapter).
2. Динамическая подгрузка данных
Теперь предстоит модернизировать код, добавив возможность подгружать данные по мере пролистывания списка. Вносить изменения придется во блоки кода, описанные выше. Начну с функции заполнения списка данными.
2.1 Модернизация функции заполнения списка
fun fillAdapter(startDate: Long = 0,
count: Int = Const.RC_ITEM_BUFFER,
clear: Boolean = true) {
// указываем в адаптере, что начинаем загрузку данных
adapter.startLoading()
val list = calManager.readMeetingsList(startDate, count)
if (list.isNotEmpty()) adapter.updateAdapter(list, clear)
// указываем, что загрузка данных закончена
adapter.setLoaded()
}
Надо сказать, что для отображения списка консультаций при запросе из Базы Данных я упорядочиваю их по возрастанию даты. И теперь функция fillAdapter принимает следующие параметры:
startDate - начальная дата, с которой берутся консультации.
_count - размер пакета данных, количество консультаций, которые будут отображаться.
clear - очищать или не очищать список.
Видно, что если вызвать функцию fillAdapter без параметров, то по умолчанию данные будут браться с самого начала, их количество будет равно некой константе RC_ITEM_BUFFER (в моем случае - 50) и список будет очищаться. Подобный вызов функции происходит в onResume:
override fun onResume() {
super.onResume()
fillAdapter()
}
Из кода видно, что изменились и вызовы функций calManager.readMeetingsList (она теперь возвращает только список консультаций с датой больше заданной и определенного количества) и adapter.updateAdapter (эта функция теперь содержит еще параметр clear - очищать ли список).
Вокруг блока кода, работающего с данными стоят строчки adapter.startLoading() и adapter.setLoaded() Это установка флага загрузки. Она необходима, чтобы при прокрутке списка не вызывалась слишком часто функция fillAdapter (подробнее смотрите далее).
2.2 Модернизация функции updateAdapter
fun updateAdapter(items: ArrayList<ListMeetings>, clear: Boolean = true){
if (clear) listItems.clear()
listItems.addAll(items)
notifyDataSetChanged()
}
При обновлении списка теперь учитывается нужно ли его очистить или нет. Если нет, то новые элементы просто добавляются в конец списка.
2.3 Подгрузка данных и флаг загрузки
class AdapterTimetable(
private var listItems: ArrayList<ListMeetings>
) :
RecyclerView.Adapter<AdapterTimetable.MyHolder>() {
private lateinit var el: RcTimetableBinding
var loadMore : MyLoadMore? = null
var isLoading = false
...
fun setLoadMore(loadMore: MyLoadMore?) {
this.loadMore = loadMore
}
fun startLoading() {
isLoading = true
}
fun setLoaded() {
isLoading = false
}
...
fun getLastItemDate(): Long {
return if (listItems.size > 0) listItems[listItems.size - 1].start else 0
}
}
interface MyLoadMore {
fun onLoadMore()
}
Сначала про флаг загрузки. В классе адаптера вводим булеву переменную isLoading. Если она установлена в true, то значит происходит загрузка элементов и пока функция fillAdapter не доступна.
Подгрузка данных будет осуществляться при помощи функции onLoadMore, которая определяется через интерфейс MyLoadMore. Установливать ее содержимое будем из активности или фрагмента, связанного с RecyclerView при помощи функции setLoadMore Честно говоря, сам не понял, что сказал - для меня это уже слишком. Объясняю, как могу, ибо сам понимаю с трудом. Но смысл в том, чтобы иметь возможность вынести эту функцию за пределы адаптера в активность.
Ну и фунция, возвращающая дату последней консультации в списке, пригодится нам далее при подгрузке данных.
2.4 Модернизация функции initAdapter
private fun initAdapter(){
el.rcView.layoutManager = LinearLayoutManager(requireContext())
adapter = AdapterTimetable(ArrayList())
el.rcView.adapter = adapter
// при прокрутке запускаем onLoadMore
val layoutManager = el.rcView.layoutManager as LinearLayoutManager
el.rcView.addOnScrollListener(object: RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
val totalItemCount = layoutManager.itemCount
val lastVisibleItem = layoutManager.findLastVisibleItemPosition()
if (!adapter.isLoading && totalItemCount <=
lastVisibleItem + Const.RC_ITEM_BUFFER / 2) {
adapter.loadMore?.onLoadMore()
}
}
})
// переопределяем функцию onLoadMore
adapter.setLoadMore(object : MyLoadMore {
override fun onLoadMore() {
fillAdapter(adapter.getLastItemDate(), Const.RC_ITEM_BUFFER, false, false)
}
})
}
К созданию адаптера добавляем две вещи: слушатель прокрутки (addOnScrollListener) и переопределение функции onLoadMore.
В слушателе прокрутки проверяем флаг загрузки и последний видимый элемент. Если положение списка близко к концу (RC_ITEM_BUFFER / 2), то подгружаем элементы при помощи модернизированной функции fillAdapter, указав в параметрах дату крайней консультации, размер пакета подгрузки и выключив очистку списка.
Ответ
Получилось вполне рабочее решение. Я его в таком виде в сети не встречал. Делюсь. Возможно, где-то в чем-то я перемудрил или не учел некоторые возможности, о которых просто пока понятия не имею. Буду рад вашим комментариям и предложениям. Есть вопрос про подгузку данных. Как вы думаете, насколько необходимо ее осуществлять при работе с БД на устройстве? Может, просто подтягивать все данные и грузить их целиком в список?
Приложение над которым я сейчас работаю: "Учет клиентов" https://play.google.com/store/apps/details?id=ru.keytomyself.customeraccounting
Собираюсь добавить возможность интеграции с Гугл Календарем. В связи с этим тоже возникает множество вопросов про списки RecyclerView. Там ведь повторяющиеся события, исключения ипрочие сложности...