image

Работа с постраничной загрузкой в мобильных приложениях — тема довольно простая и никаких подводных камней не таит.

С подобной задачей разработчик встречается довольно часто и соответственно раз за разом делать одно и тоже скучно и лениво.

Но любую даже самую обычную задачу можно решить другим, более интересным путем, получить новые знания и просто размять мозги.

Пагинация


Если вкратце, то вся работа по реализации пагинации состоит в пунктах, перечисленных ниже:

  1. Добавить слушателя к recycler view;
  2. Загрузить первичные данные;
  3. Словить коллбэк, когда пользователь прокрутил список;
  4. Показать загрузку в списке после всех элементов;
  5. Отправить запрос на получение новых элементов;
  6. Снова отобразить данные.

В нашем случае механизм постраничной подгрузки данных был реализован, но не было отображения загрузки во время прокрутки пользователем списка.

Окей, сделать это один раз в одном приложении не проблема. Если есть несколько экранов, где необходим данный функционал, то при можно написать базовый адаптер, который умеет работать с дополнительным типом view.

Но, что если задача несколько сложнее?

Допустим есть несколько не новых проектов, у которых есть один core модуль с базовым функционалом.

Так как проектам далеко не первый год, то как следствие накопилось немало легаси кода, связанного с этой задачей, а именно множество адаптеров с различными базовыми классами.

Как можно решить задачу?


Создать новый базовый адаптер, который будет уметь показывать прогресс загрузки, переписать все старые адаптеры + экраны, на которых используется адаптеры, потому что придется на каждом экране, в каждом презентере или вью модели управлять состоянием отображения загрузки.

Это может быть довольно трудозатратно. А также крупный рефакторинг может привести к некоторому количеству новых ошибок, особенно если покрытие тестами в проектах стремится к нулю. Плюс высокая нагрузка на отдел QA с регресс-тестированием всех экранов с пагинацией по нескольким приложениям.

Как было бы здорово написать такой код, который требует от клиента-разработчика минимум временных вложений для интеграции базового решения в своей фиче. Чтобы не пришлось реализовывать логику управления отображения прогресса загрузки — показа и скрытия.

В этот момент я подумал об использовании класса ItemDecoration.

Почему бы не использовать этот класс для инкапсуляции всей работы связанной с

  • определением, когда нужно показать загрузку и когда нужно ее скрыть
  • отрисовкой прогресса загрузки

К сожалению, в интернете не нашлось ни одного готового решения — либо никто не пытался это реализовать, либо просто не поделился опытом после реализации.

Что же нужно для реализации отображения загрузки при пагинации при помощи ItemDecoration?


  • Сделать отступ для отрисовки загрузки;
  • Понимать в реализации ItemDecoration, когда нам необходимо показать прогресс;
  • Отрисовать загрузку;

Отступ


Как сделать отступ знает любой разработчик, который хоть раз сам создавал SpacingItemDecoration. В этом нам поможет метод ItemDecoration getItemOffsets:

    override fun getItemOffsets(outRect: Rect, view: View,
                                recyclerView: RecyclerView, 
                                state: RecyclerView.State) {

        super.getItemOffsets(outRect, view, parent, state)
    }

У любого элемента списка мы можем добавить отступы с любой стороны. Конкретно в нашем случае нам необходимо понять, что пользователь докрутил список до конца и у последнего элемента сделать снизу отступ равный величине будущего отображаемого прогресса загрузки + отступы от самого прогресса.

Как понять, что список прокручен до самого низа?

Нам поможет в этом код, представленный ниже:

 private fun isLastItem(recyclerView: RecyclerView, view: View): Boolean {
        val lastItemPos = recyclerView.getChildAdapterPosition(view)
        return lastItemPos == recyclerView.adapter!!.itemCount - 1
    }

Итого мы имеем код для определения и реализации отступа:

    override fun getItemOffsets(outRect: Rect, view: View,
                                recyclerView: RecyclerView, 
                                state: RecyclerView.State) {

        super.getItemOffsets(outRect, view, recyclerView, state)

        when (isLastItem(recyclerView, view)) {
            true -> outRect.set(Rect(0, 0, 0, 120))
            else -> outRect.set(Rect(0, 0, 0, 0))
        }
    }

    private fun isLastItem(recyclerView: RecyclerView, view: View): Boolean {
        val lastItemPos = recyclerView.getChildAdapterPosition(view)
        return lastItemPos == recyclerView.adapter!!.itemCount - 1
    }

Треть дела сделано!

Определяем время показа прогресса загрузки и вызываем отрисовку прогресса


В этом нам поможет метод ItemDecoration onDrawOver:

    override fun onDrawOver(canvas: Canvas, 
                            recyclerView: RecyclerView,
                            state: RecyclerView.State) {

        super.onDrawOver(canvas, recyclerView, state)
    }

Метод onDrawOver отличается от метода onDraw только порядком отрисовки. В onDrawOver decorations будут отрисованы только после отрисовки самого элемента списка.

В методе onDrawOver нам необходимо понять в какой момент нам необходимо отрисовать прогресс, в какой момент его убрать и вызвать обновления у элементов списка с целью скрытия уже ненужного отступа.

Код для реализации действий, описанных выше:

    override fun onDrawOver(canvas: Canvas, 
                            recyclerView: RecyclerView,
                            state: RecyclerView.State) {

        super.onDrawOver(canvas, recyclerView, state)

        when (showLoading(recyclerView)) {
            true -> {
                PaginationProgressDrawer.drawSpinner(recyclerView, canvas)
                isProgressVisible = true
            }
            else -> {
                if (isProgressVisible) {
                    isProgressVisible = false
                    recyclerView.invalidateItemDecorations()
                }
            }
        }
    }

    private fun showLoading(recyclerView: RecyclerView): Boolean {

        val manager = recyclerView.layoutManager as LinearLayoutManager
        val lastVisibleItemPos = manager.findLastCompletelyVisibleItemPosition()

        return lastVisibleItemPos != -1 && 
                        lastVisibleItemPos >= recyclerView.adapter!!.itemCount - 1
    }

Отрисовка прогресса


Код отрисовки довольно объемный и будет представлен в отдельном файле, ссылку на который я представлю ниже.

Хочется остановиться только на нюансах, которые необходимы для реализации.

Вся работа по отрисовке происходит на канвасе. Соответственно, будет необходимо настроить экземпляр объекта Paint и рисовать дуги, с указанием начального и конечного углов.

Важный нюанс в том, что для отрисовки максимально похожей на обычный ProgressBar необходимо создать собственный аналог интерполятора для того, чтобы анимация отрисовки происходила нелинейно.

Для понимания работы интерполяторов и осознания того, что вы хотите получить от своей анимации я рекомендую ознакомиться с графиками, на которых изображена работа различных видов интерполяторов, например, вот в этой статье

Ссылки на код:

PaginationLoadingDecoration
PaginationProgressDrawer

При необходимости можно создать ProgressDrawer интерфейс и подменять реализации в PaginationLoadingDecoration.

Видео с демонстрацией загрузки:


Благодарю за прочтение, приятного кодинга :)

Комментарии (6)


  1. svp7093
    25.09.2019 23:22

    Подгрузку имеет смысл активировать заранее, например за пару страниц до конца текущего буфера. Тогда пользователь ее вообще может не заметить.


    1. SemyonVelichko Автор
      25.09.2019 23:24

      Вы определенно правы и я очень рад первому комментарию)
      На самом деле увидит ли пользователь индикатор загрузки зависит только от того с какой скоростью пользователь листает ленту и насколько быстро загружаются новые данные.
      То есть шанс того, что придется отобразить ProgressBar есть всегда.


  1. goodvin1709
    26.09.2019 12:52

    Что происходит если возникает ошибка загрузки, или во время загрузки пользователь проскролит вверх не дождався загрузки?


    1. SemyonVelichko Автор
      26.09.2019 13:31

      Если пользователь проскроллит вверх — то данные просто добавятся в конец списка без визуальных изменений для пользователя.
      Если возникает ошибка загрузки, то данную ситуацию можно обработать, указав Item Decoration, что показывать пагинацию больше не требуется, воспользовавшись булевой переменной showPaginationLoading (также этот флаг необходимо переключить, когда данные были загружены полностью и более данных не будет).


  1. agent10
    26.09.2019 14:49

    Идея не плохая, но минус в том, что если надо показать другой стиль или более сложный вид, но придётся переписывать рисование на канве.


    1. SemyonVelichko Автор
      26.09.2019 14:56

      Абсолютно верно подмечено.
      В любом случае, если захочется какой-то особенный ProgressBar, то придется создать кастомную View и точно также отрисовать загрузку в методе onDraw(). Там, конечно, сделать все будет намного проще, потому что можно настроить анимации и интерполяторы.
      Либо как вариант — просто подключить библиотеку Lottie и проигрывать анимацию при появлении прогресса в элементе списка.