Android Studio. Kotlin. Динамическая подгрузка данных в список RecyclerView

от автора

Долго я искал в сети способ сделать так, чтобы данные при построении списка RecyclerView не загружались целиком, а подгружались по мере его пролистывания пользователем. Несколько совершенно разных решений находил на StackOverflow. Пробовал применить — работало, но каждый раз, как-то криво и не надежно. После нескольких месяцев работы над проектом в режиме «Когда все дела сделаны и дети слезли с шеи», я наконец достиг, как мне кажется, идеального решения, чем и хочу поделиться в этой статье.

Задача

Мне нужно было отобразить для пользователя моего приложения список клиентов, консультаций и расходов из Базы Данных приложения в разных фрагментах. Один грамотный программист Баз Данных, по совместительству — мой шурин, объяснил мне что лучше отображать не все данные сразу, а только те, которые видны пользователю и реализовать возможность подгружать данные из БД по мере необходимости.

Скриншот главного экрана моего приложения
Скриншот главного экрана моего приложения

Решение

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.

Макет элемента спика
Макет элемента спика

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. Там ведь повторяющиеся события, исключения ипрочие сложности…


ссылка на оригинал статьи https://habr.com/ru/post/647037/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *