Всем привет!
Меня зовут Антон Князев, senior Android-разработчик компании Omega-R. В течение последних семи лет я профессионально занимаюсь разработкой мобильных приложений и решаю сложные проблемы нативной разработки.
Хочу поделиться способами расширения RecyclerView, наработанными нашей командой и мной. Они станут надежной базой для создания нестандартных списков в приложениях.
Каждое приложение по-своему уникально благодаря своей идее, дизайну и команде специалистов. Удачные решения часто хочется перенести из одного проекта в другой. Поэтому вместо простого копирования логично создать отдельную библиотеку, которую бы использользовала и совершенствовала вся команда.
Команда решила создавать маленькие библиотеки, которые улучшают и ускоряют разработку приложений, и выкладывать их в публичный репозиторий GitHub. Это позволяет легко подключать библиотеку в проектах через JitPack и дает заказчикам гарантию, что в коде нет ничего “криминального”.
Первая библиотека, которую мы выложили на GitHub, является простым расширением RecyclerView.
Начнем с проблем, которые она решала:
- Нет дефолтного layoutManager – это неудобно, так как часто приходится выбирать один и тот же LinearLayoutManager;
- Нет возможности добавлять divider и item space через xml – тоже неудобно, так как необходимо добавлять либо в item layout, либо через ItemDecorator;
- Нельзя просто добавить 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 с помощью следующих атрибутов:
- divider – определяет drawable, может быть назначен и цвет напрямую;
- dividerShow (beginning, middle, end) – флаги, которые определяют, где рисовать;
- dividerHeight – задает высоту divider, в случае с цветом становится особенно нужным;
- dividerPadding, dividerPaddingStart, dividerPaddingEnd – отступы: общий, с начала, с конца;
- dividerAlpha – определяет прозрачность;
- itemSpace – отступы между элементами списка.
Все эти атрибуты применяются непосредственно к нашему OmegaRecyclerView с помощью двух специальных ItemDecoration.
Один из ItemDecoration добавляет отступы между элементами, второй – рисует сами divider. Необходимо учитывать, что при наличии отступа между элементами второй ItemDecoration рисует divider посередине отступа. Возможности поддерживать все возможные варианты LayoutManager нет, поэтому поддерживаем только LinearLayoutManager и GridLayoutManager. Также следует учитывать ориентацию списка для LinearLayoutManager.
Для того чтобы упростить код, выделим специальный DividerDecorationHelper, который будет читать и записывать относительные данные в Rect, зависящие от ориентации и порядка следования (обратный или прямой). В нем будут методы setStart, setEnd и getStart, getEnd.
Создадим базовый ItemDecoration для двух классов, в котором будет содержаться общая логика, а именно:
- проверка, что layoutManager является наследником LinearLayoutManager;
- вычисление текущей ориентации и порядка следования;
- определение подходящего 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:
- Мы уже в 2018 году создали свой ViewPager из RecyclerView. Причем, в нашем ViewPager есть бесконечный скролл;
- ExpandableRecyclerView – специальный класс для добавления раскрывающего списка, с возможностью выбора анимации раскрытия;
- StickyHeader – специфический элемент списка, который можно добавлять через адаптер.
Всё это является результатом наработанного опыта Omega-R. Эволюция мастерства разработчиков проходит через несколько стадий. Сначала появляется желание скопировать код из другого проекта или сделать что-то похожее на него. Потом приходит стадия, когда необходимо зафиксировать накопленный опыт и создать отдельный репозиторий.
На следующей стадии начинаешь целенаправленно создавать фичи, которые не решался делать в проектах. Это может занять значительное время, но позволяет создать задел на будущее и ускорить разработку в новых проектах. Приглашаю каждого, кто сталкивается с трудностями в разработке, познакомиться с нашими решениями в GitHub-репозитории Omega-R.