July 14, 2020

Кодовая база. Расширяем RecyclerView

Всем привет!

Меня зовут Антон Князев, senior Android-разработчик компании Omega-R (см. источник). В течение последних семи лет я профессионально занимаюсь разработкой мобильных приложений и решаю сложные проблемы нативной разработки.

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

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

Команда решила создавать маленькие библиотеки, которые улучшают и ускоряют разработку приложений, и выкладывать их в публичный репозиторий GitHub. Это позволяет легко подключать библиотеку в проектах через JitPack и дает заказчикам гарантию, что в коде нет ничего “криминального”.

Первая библиотека, которую мы выложили на GitHub, является простым расширением RecyclerView.

Начнем с проблем, которые она решала:

  1. Нет дефолтного layoutManager – это неудобно, так как часто приходится выбирать один и тот же LinearLayoutManager.
  2. Нет возможности добавлять divider и item space через xml – тоже неудобно, так как необходимо добавлять либо в item layout, либо через ItemDecorator.
  3. Нельзя просто добавить header и footer через xml – это возможно только через отдельный ViewHolder.

Проблемы некритичные, но создают неудобства и увеличивают время разработки.

1. Проблема: нет дефолтного layoutManager

Разработчики RecyclerView не предусмотрели возможности выбора LayoutManager по умолчанию. Задать layoutManager можно следующими способами:

1. Через XML в атрибуте app:layoutManager=”LinearLayoutManager”:

<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView
    ...
     app:layoutManager="LinearLayoutManager"/>

2. Через код:

recyclerView.layoutManager = LinearLayoutManager(this)

По нашему опыту, в большинстве случаев нужен именно LinearLayoutManager.

Вот несколько примеров таких списков из наших приложений ITProTV, Простой Мир и Dexen:

Решение: добавим дефолтный layoutManager

В OmegaRecyclerView добавляется лишь 3 строчки:

if (layoutManager == null)  {
    layoutManager = LinearLayoutManager(context, attrs, defStyleAttr, 0)
}

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

<?xml version="1.0" encoding="utf-8"?>
<com.omega_r.libs.omegarecyclerview.OmegaRecyclerView
    android:id="@+id/recyclerview"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

2. Проблема: нет возможности добавлять divider и item space через xml

Добавлять divider приходится довольно часто при использовании RecyclerView. Например, в проекте “Простой Мир” один из экранов был с таким нестандартным divider (горизонтальная линия):

Из этого макета видно, что:

  • используется divider между элементами и в самом конце;
  • используется item space.

Каким образом это можно реализовать в Android стандартным путем?

Способ 1

Самый очевидный способ – включить divider как элемент ImageView:

 <RelativeLayout
   ...
   android:paddingStart="20dp"
   android:paddingTop="12dp"
   android:paddingEnd="20dp"
   android:paddingBottom="12dp">

   ...

   <ImageView
       ...
       android:layout_alignParentBottom="true"
       android:src="@drawable/divider"/>

</RelativeLayout>

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

Способ 2

Другим способом является использование DividerItemDecoration, который может нарисовать этот divider. Для него необходимо дополнительно создать drawable:

<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:left="32dp">
        <shape android:shape="rectangle">
            <size
                android:width="1dp"
                android:height="1dp" />
            <solid android:color="@color/gray_dark" />
        </shape>
    </item>
</layer-list>

Для добавления отступа требуется написать свой ItemDecoration:

class VerticalSpaceItemDecoration(
    private val verticalSpaceHeight: Int
): RecyclerView.ItemDecoration {

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        parent: RecyclerView,
        state: RecyclerView.State
    ) {
        outRect.bottom = verticalSpaceHeight
    }
}

DividerItemDecoration прост: он рисует divider всегда под каждым элементом списка.
Но в случае изменения требований придется искать другое решение.

Решение: дополним возможностью добавлять divider и item space через xml

Итак, наш OmegaRecyclerView должен уметь добавлять divider с помощью следующих атрибутов:

  1. divider – определяет drawable, может быть назначен и цвет напрямую;
  2. dividerShow (beginning, middle, end) – флаги, которые определяют, где рисовать;
  3. dividerHeight – задает высоту divider, в случае с цветом становится особенно нужным;
  4. dividerPadding, dividerPaddingStart, dividerPaddingEnd – отступы: общий, с начала, с конца;
  5. dividerAlpha – определяет прозрачность;
  6. itemSpace – отступы между элементами списка.

Все эти атрибуты применяются непосредственно к нашему OmegaRecyclerView с помощью двух специальных ItemDecoration.

Один из ItemDecoration добавляет отступы между элементами, второй – рисует сам divider. Необходимо учитывать, что при наличии отступа между элементами второй ItemDecoration рисует divider посередине отступа. Возможности поддерживать все возможные варианты LayoutManager нет, поэтому поддерживаем только LinearLayoutManager и GridLayoutManager. Также следует учитывать ориентацию списка для LinearLayoutManager.

Для того чтобы упростить код, выделим специальный DividerDecorationHelper, который будет читать и записывать относительные данные в Rect, зависящие от ориентации и порядка следования (обратный или прямой). В нем будут методы setStart, setEnd, getStart, getEnd.

Создадим базовый ItemDecoration для двух классов, в котором будет содержаться общая логика, а именно:

  1. проверка, что layoutManager является наследником LinearLayoutManager;
  2. вычисление текущей ориентации и порядка следования;
  3. определение подходящего DividerDecorationHelper.

В SpaceItemDecoration переопределим только один метод getItemOffset, который будет добавлять отступы:

override fun getItemOffset(
    outRect: Rect,
    parent: RecyclerView,
    helper: DividerDecorationHelper,
    position: Int,
    itemCount: Int
) {
    if (isShowBeginDivider() || countBeginEndPositions <= position) {
        helper.setStart(outRect, space)
    }
    if (isShowEndDivider() && position == itemCount - countBeginEndPositions) {
        helper.setEnd(outRect, space)
    }
}

Следующий DividerItemDecoration будет непосредственно рисовать divider. Он должен учитывать отступ между элементами и рисовать divider посередине. Для начала переопределим метод getItemOffset для случая, когда отступ не задан, но divider требуется для рисования.

override fun getItemOffset(
    outRect: Rect,
    parent: RecyclerView,
    helper: DividerDecorationHelper,
    position: Int,
    itemCount: Int
) {
    if (position == 0 && isShowBeginDivider()) {
        helper.setStart(outRect, dviderSize)
    }
    if (position != 0 && isShowMiddleDivider()) {
        helper.setStart(outRect, dividerSize)
    }
    if (position == itemCount - 1 && isShowEndDivider()) {
        helper.setEnd(outRect, dividerSize)
    }
}

Также добавим опцию, которая позволит DividerItemDecoration спрашивать adapter, можно ли рисовать выше или ниже выбранного элемента. Для реализации такой возможности создадим свой адаптер, наследуемый от стандартного со следующими методами:

open fun isDividerAllowedAbove(position: Int): Boolean {
    return true
}

open fun isDividerAllowedBelow(position: Int): Boolean {
    return true
}

Далее переопределим метод onDrawOver, чтобы рисовать divider поверх нарисованных элементов. В этом методе надо пройтись по всем элементам, видимым на экране (через getChildAt), и при необходимости нарисовать этот divider. Также надо учесть, что из атрибута dividerDrawable может прийти и цвет, у которого нет высоты. Для такого случая высоту можно взять из атрибута dividerHeight.

3. Проблема: нельзя напрямую добавить header и footer через xml

В RecyclerView невозможно добавлять view через xml, но есть другие способы сделать это.

Способ 1

Один из очевидных способ добавлении view – через adapter. Причем необходимо отличать header и footer в adapter при введении своего идентификатора для viewType.

fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder? {
    val inflater = LayoutInflater.from(parent.context)
    return when (viewType) {
        TYPE_HEADER -> {
            val headerView: View = inflater.inflate(R.layout.item_header, parent, false)
            HeaderViewHolder(itemView)
        }
        TYPE_ITEM -> {
            val itemView: View = inflater.inflate(R.layout.item_view, parent, false)
            ItemViewHolder(itemView)
        }
        else -> null
    }
}

Способ 2

Немного другой способ, но тоже через adapter. Начиная с recyclerview:1.2.0-alpha02, появился MergeAdapter, который позволяет соединять несколько адаптеров в один, делая код чище.

val mergeAdapter = MergeAdapter(headerAdapter, itemAdapter,  footerAdapter)
recyclerView.adapter = mergeAdapter

Решение: дополним возможностью простого добавления header и footer через xml

Первое, что нужно сделать, – перехватить добавление view в нашем OmegaRecyclerView, когда идет процесс inflate. Для этого следует переопределить метод addView и добавить себе header и footer view. Этот метод используется самим RecyclerView для дополнения видимыми элементами списка. Но view, добавленные через xml, не будут иметь ViewHolder, что в конечном итоге вызовет NullPointerException.

Итак, нам надо определить, когда view добавляется во время inflate. К счастью, существует protected метод onFinishInflate, который вызывается при завершении процесса inflate. Поэтому при вызове этого метода помечаем, что процесс inflate завершен.

protected override fun onFinishInflate() {
    super.onFinishInflate()
    finishedInflate = true
}

Таким образом, метод addView будет выглядеть следующим образом:

override fun addView(view: View, index: Int, params: ViewGroup.LayoutParams) {
    if (finishedInflate) {
        super.addView(view, index, params)
    } else {
        // save header and footer views
    }
}

Далее необходимо запомнить все эти добавочные view и передать в специальный адаптер по типу MergeAdapter.

Также нам удалось решить еще одну проблему: при вызове метода findViewById наши view возвращаться не будут. Для решения этой проблемы переопределим метод findViewTraversal: в нем необходимо сравнить id найденных нами view и вернуть view при совпадении. Поскольку этот метод скрыт, просто пишем его, не указывая, что он override.

С этими и другими полезными фичами с подробным описанием вы можете познакомиться в нашей библиотеке OmegaRecyclerView:

  1. Мы уже в 2018 году создали свой ViewPager из RecyclerView. Причем в нашем ViewPager есть бесконечный скролл.
  2. ExpandableRecyclerView – специальный класс для добавления раскрывающегося списка с возможностью выбора анимации раскрытия.
  3. StickyHeader – специфический элемент списка, который можно добавлять через адаптер.

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

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

Источник: Кодовая база. Расширяем RecyclerView