Как стать автором
Обновить

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

Время на прочтение8 мин
Количество просмотров12K

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

Теги:
Хабы:
Рейтинг0
Комментарии26

Публикации