Кодовая база. Расширяем RecyclerView
Всем привет!
Меня зовут Антон Князев, 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.
Источник: Кодовая база. Расширяем RecyclerView