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

image
Всем привет!

Меня зовут Антон Князев, 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:

image

Решение: добавим дефолтный 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:

image

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

  • используются 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


image

В 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.
AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 1

Only users with full accounts can post comments. Log in, please.