RecyclerView.ItemDecoration: используем по максимуму

Привет, дорогой читатель Хабра. Меня зовут Олег Жило, последние 4 года я Android-разработчик в Surf. За это время я поучаствовал в разного рода крутых проектах, но и с легаси-кодом поработать довелось.

У этих проектов есть как минимум одна общая деталь: везде есть список с элементами. Например, список контактов телефонной книги или список настроек вашего профиля.

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



Кроме данных в списке в RecyclerView есть ещё важные элементы декора, например, разделители ячеек, полосы прокрутки. И вот тут нам поможет RecyclerView.ItemDecoration отрисовать весь декор и не плодить лишние View в вёрстке ячеек и экрана.

ItemDecoration представляет из себя абстрактный класс с 3-мя методами:

Метод для отрисовки декора до отрисовки ViewHolder

public void onDraw(Canvas c, RecyclerView parent, State state)

Метод для отрисовки декора после отрисовки ViewHolder

public void onDrawOver(Canvas c, RecyclerView parent, State state)

Метод для выставления отступов у ViewHolder при заполнении RecyclerView

public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)

По сигнатуре методов onDraw* видно, что для отрисовки декора используется 3 основных компонента.

  • Canvas — для отрисовки необходимого декора
  • RecyclerView — для доступа к параметрам самого RecyclerVIew
  • RecyclerView.State — содержит информацию о состоянии RecyclerView

Подключение к RecyclerView


Для подключения экземпляра ItemDecoration к RecyclerView есть два метода:

public void addItemDecoration(@NonNull ItemDecoration decor)
public void addItemDecoration(@NonNull ItemDecoration decor, int index)

Все подключенные экземпляры RecyclerView.ItemDecoration добавляются в один список и отрисовываются сразу все.

Также RecyclerView имеет дополнительные методы для манимуляции с ItemDecoration.
Удаление ItemDecoration по индексу

public void removeItemDecorationAt(int index)

Удаление экземпляра ItemDecoration

public void removeItemDecoration(@NonNull ItemDecoration decor)

Получить ItemDecoration по индексу

public ItemDecoration getItemDecorationAt(int index)

Получить текущее количество подключенных ItemDecoration в RecyclerView

public int getItemDecorationCount()

Перерисовать текущий список ItemDecoration

public void invalidateItemDecorations()

В SDK уже есть наследники RecyclerView.ItemDecoration, например, DeviderItemDecoration. Он позволяет отрисовать разделители для ячеек.

Работает очень просто, необходимо использовать drawable и DeviderItemDecoration отрисует его в качестве разделителя ячеек.

Создадим divider_drawable.xml:

<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <size android:height="1dp" />
    <solid android:color="@color/gray_A700" />
</shape>

И подключим DividerItemDeoration к RecyclerView:

val dividerItemDecoration = DividerItemDecoration(this, RecyclerView.VERTICAL)
dividerItemDecoration.setDrawable(resources.getDrawable(R.drawable.divider_drawable))
recycler_view.addItemDecoration(dividerItemDecoration)

Получим:


Идеально подходит для простых случаев.

Под «капотом» DeviderItemDecoration всё элементарно:


final int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
     final View child = parent.getChildAt(i);
     parent.getDecoratedBoundsWithMargins(child, mBounds);
     final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
     final int top = bottom - mDivider.getIntrinsicHeight();
     mDivider.setBounds(left, top, right, bottom);
     mDivider.draw(canvas);
}

На каждый вызов onDraw(...) циклом проходим по всем текущим View в RecyclerView и отрисовываем переданный drawable.

Но экран может содержать и более сложные элементы вёрстки, чем список одинаковых элементов. На экране могут присутствовать:

а. Несколько видов ячеек;
b. Несколько видов дивайдеров;
c. Ячейки могут иметь закругленные края;
d. Ячейки могут иметь разный отступ по вертикали и горизонтали в зависимости от каких-то условий;
e. Всё вышеперечисленное сразу.

Давайте рассмотрим пункт e. Поставим себе сложную задачу и рассмотрим её решение.

Задача:

  • На экране есть 3 вида уникальных ячеек, назовём их a, b и с.
  • Все ячейки имеют отступ в 16dp по горизонтали.
  • Ячейка b имеет ещё отступ в 8dp по вертикали.
  • Ячейка a имеет закруглённые края сверху, если это первая ячейка в группе и снизу, если это последняя ячейка в группе.
  • Между ячейками с отрисовываются дивайдеры, НО после последней ячейки в группе дивайдера быть не должно.
  • На фоне ячейки c рисуется картинка с эффектом параллакса.

Должно в итоге получиться так:


Рассмотрим варианты решения:

Заполнение списка ячейками разного типа.

Можно написать свой Adapter, а можно использовать любимую библиотеку.
Я буду использовать EasyAdapter.

Выставление отступов ячейкам.

Тут есть три способа:

  1. Проставить paddingStart и paddingEnd для RecyclerView.
    Данное решение не подойдёт, если не у всех ячеек отступ одинаковый.
  2. Проставить layout_marginStart и layout_marginEnd у ячейки.
    Придётся всем ячейкам в списке проставлять одни и те же отступы.
  3. Написать реализацию ItemDecoration и переопределить метод getItemOffsets.
    Уже лучше, решение получится более универсальное и переиспользуемое.

Закругление углов у групп ячеек.

Решение кажется очевидным: хочется сразу добавить какой-нибудь enum {Start, Middle, End } и проставлять его ячейке вместе с данными. Но сразу всплывают минусы:

  • Модель данных в списке усложняется.
  • Для таких манипуляций придётся заранее просчитывать какой enum проставлять каждой ячейке.
  • После удаления/добавления элемента в список придётся это пересчитывать заново.
  • ItemDecoration. Понять какая это ячейка в группе и правильно отрисовать фон можно в методе onDraw* ItemDecoration’a.

Рисование дивайдеров.

Рисование дивайдеров внутри ячейки — плохая практика, так как в итоге получится усложненная вёрстка, на сложных экранах начнутся проблемы с динамическим показом дивайдеров. И поэтому ItemDecoration снова выигрывает. Готовый DeviderItemDecoration из sdk нам не подойдёт, так как отрисовывает дивайдеры после каждой ячейки, и это никак не решается из коробки. Надо писать свою реализацию.

Паралакс на фоне ячейки.

На ум может прийти идея проставить RecyclerView OnScrollListener и использовать какую-нибудь кастомную View для отрисовки картинки. Но и здесь нас снова выручит ItemDecoration, так как он имеет доступ к Canvas Recycler’а и ко всем нужным параметрам.

Итого, нам необходимо написать как минимум 4 реализации ItemDecoration. Очень хорошо, что все пункты можем свести к работе только с ItemDecoration и не трогать вёрстку и бизнес логику фичи. Плюс, все реализации ItemDecoration получится переиспользовать, если у нас есть похожие кейсы в приложении.

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

Каких целей хотелось добиться:

  1. Писать как можно меньше наследников ItemDecoration.
  2. Отделить логику отрисовки на Canvas и выставления отступов.
  3. Иметь преимущества работы с методами onDraw и onDrawOver.
  4. Сделать более гибкие в настройке декораторы (например, отрисовка дивайдеров по условию, а не всех ячеек).
  5. Сделать решение без привязки к Дивайдерам, ведь ItemDecoration способен на большее, чем рисование горизонтальных и вертикальных линий.
  6. Этим можно легко пользоваться, смотря на сэмпл проект.

В итоге у нас получилась библиотека RecyclerView decorator.

Библиотека имеет простой Builder интерфейс, отдельные интерфейсы для работы с Canvas и отступами, также возможность работать с методами onDraw и onDrawOver. Реализация ItemDecoration всего одна.

Давайте вернёмся к нашей задаче и посмотрим, как её решить с помощью библиотеки.
Builder нашего декоратора выглядит просто:


Decorator.Builder()
            .underlay()
            ...
            .overlay()
            ...
            .offset()
            ...
            .build()

  • .underlay(...) — нужен для отрисовки под ViewHolder.
  • .overlay(...) — нужен для отрисовки над ViewHolder.
  • .offset(...) — используется для выставления отступа ViewHolder.

Для отрисовки декора и выставления отступов используется 3 интерфейса.

  • RecyclerViewDecor — отрисовывает декор на RecyclerView.
  • ViewHolderDecor — отрисовывает декор на RecyclerView, но даёт доступ к ViewHolder.
  • OffsetDecor — используется для выставления отступов.

Но это не всё. ViewHolderDecor и OffsetDecor можно привязать к конкретному ViewHolder с помощью viewType, что позволяет комбинировать несколько видов декоров на одном списке и даже ячейке. Если viewType не передавать, то ViewHolderDecor и OffsetDecor будут применяться ко всем ViewHolder в RecyclerView. RecyclerViewDecor такой возможности не имеет, так как рассчитан на работу с RecyclerView в общем, а не с ViewHolder’ами. Плюс один и тот же экземпляр ViewHolderDecor/RecyclerViewDecor можно передавать как в overlay(...) так underlay(...).

Приступим к написанию кода

В библиотеке EasyAdapter для создания ViewHolder используются ItemController’ы. Если коротко, они отвечают за создание и идентификацию ViewHolder. Для нашего примера хватит одного контроллера, который может отображать разные ViewHolder. Главное, чтобы viewType был уникальный для каждой вёрстки ячейки. Выглядит это следующим образом:

private val shortCardController = Controller(R.layout.item_controller_short_card)
private val longCardController = Controller(R.layout.item_controller_long_card)
private val spaceController = Controller(R.layout.item_controller_space)

Для выставления отступов нам нужен наследник OffsetDecor:

class SimpleOffsetDrawer(
    private val left: Int = 0,
    private val top: Int = 0,
    private val right: Int = 0,
    private val bottom: Int = 0
) : Decorator.OffsetDecor {

    constructor(offset: Int) : this(offset, offset, offset, offset)

    override fun getItemOffsets(
        outRect: Rect,
        view: View,
        recyclerView: RecyclerView,
        state: RecyclerView.State
    ) {
        outRect.set(left, top, right, bottom)
    }
}

Для отрисовки закруглённых углов у ViewHolder нужен наследник ViewHolderDecor. Тут нам понадобится OutlineProvider, чтобы press-state тоже обрезался по краям.

class RoundDecor(
    private val cornerRadius: Float,
    private val roundPolitic: RoundPolitic = RoundPolitic.Every(RoundMode.ALL)
) : Decorator.ViewHolderDecor {

    override fun draw(
        canvas: Canvas,
        view: View,
        recyclerView: RecyclerView,
        state: RecyclerView.State
    ) {

        val viewHolder = recyclerView.getChildViewHolder(view)
        val nextViewHolder =
            recyclerView.findViewHolderForAdapterPosition(viewHolder.adapterPosition + 1)
        val previousChildViewHolder =
            recyclerView.findViewHolderForAdapterPosition(viewHolder.adapterPosition - 1)

        if (cornerRadius.compareTo(0f) != 0) {
            val roundMode = getRoundMode(previousChildViewHolder, viewHolder, nextViewHolder)
            val outlineProvider = view.outlineProvider
            if (outlineProvider is RoundOutlineProvider) {
                outlineProvider.roundMode = roundMode
                view.invalidateOutline()
            } else {
                view.outlineProvider = RoundOutlineProvider(cornerRadius, roundMode)
                view.clipToOutline = true
            }
        }
    }
}

Для рисования дивайдеров напишем ещё одного наследника ViewHolderDecor:

class LinearDividerDrawer(private val gap: Gap) : Decorator.ViewHolderDecor {

    private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val alpha = dividerPaint.alpha

    init {
        dividerPaint.color = gap.color
        dividerPaint.strokeWidth = gap.height.toFloat()
    }

    override fun draw(
        canvas: Canvas,
        view: View,
        recyclerView: RecyclerView,
        state: RecyclerView.State
    ) {
        val viewHolder = recyclerView.getChildViewHolder(view)
        val nextViewHolder = recyclerView.findViewHolderForAdapterPosition(viewHolder.adapterPosition + 1)

        val startX = recyclerView.paddingLeft + gap.paddingStart
        val startY = view.bottom + view.translationY
        val stopX = recyclerView.width - recyclerView.paddingRight - gap.paddingEnd
        val stopY = startY

        dividerPaint.alpha = (view.alpha * alpha).toInt()

        val areSameHolders =
            viewHolder.itemViewType == nextViewHolder?.itemViewType ?: UNDEFINE_VIEW_HOLDER

        val drawMiddleDivider = Rules.checkMiddleRule(gap.rule) && areSameHolders
        val drawEndDivider = Rules.checkEndRule(gap.rule) && areSameHolders.not()

        if (drawMiddleDivider) {
            canvas.drawLine(startX.toFloat(), startY, stopX.toFloat(), stopY, dividerPaint)
        } else if (drawEndDivider) {
            canvas.drawLine(startX.toFloat(), startY, stopX.toFloat(), stopY, dividerPaint)
        }
    }
}

Для настройки нашего дивадера будем использовать класс Gap.kt:

class Gap(
    @ColorInt val color: Int = Color.TRANSPARENT,
    val height: Int = 0,
    val paddingStart: Int = 0,
    val paddingEnd: Int = 0,
    @DividerRule val rule: Int = MIDDLE or END
)

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

Остался последний наследник ViewHolderDecor. Для рисования картинки эффектом параллакса.

class ParallaxDecor(
    context: Context,
    @DrawableRes resId: Int
) : Decorator.ViewHolderDecor {

    private val image: Bitmap? = AppCompatResources.getDrawable(context, resId)?.toBitmap()

    override fun draw(
        canvas: Canvas,
        view: View,
        recyclerView: RecyclerView,
        state: RecyclerView.State
    ) {

        val offset = view.top / 3
        image?.let { btm ->
            canvas.drawBitmap(
                btm,
                Rect(0, offset, btm.width, view.height + offset),
                Rect(view.left, view.top, view.right, view.bottom),
                null
            )
        }
    }
}

Соберём теперь всё вместе.

private val decorator by lazy {
        Decorator.Builder()
            .underlay(longCardController.viewType() to roundDecor)
            .underlay(spaceController.viewType() to paralaxDecor)
            .overlay(shortCardController.viewType() to dividerDrawer2Dp)
            .offset(longCardController.viewType() to horizontalOffsetDecor)
            .offset(shortCardController.viewType() to horizontalOffsetDecor)
            .offset(spaceController.viewType() to horizontalAndVerticalOffsetDecor)
            .build()
    }

Инициализируем RecyclerView, добавим ему наш декоратор и контроллеры:

private fun init() {
        with(recycler_view) {
            layoutManager = LinearLayoutManager(this@LinearDecoratorActivityView)
            adapter = easyAdapter
            addItemDecoration(decorator)
            setPadding(0, 16.px, 0, 16.px)
        }

        ItemList.create()
            .apply {
                repeat(3) {
                    add(longCardController)
                }
                add(spaceController)
                repeat(5) {
                    add(shortCardController)
                }
            }
            .also(easyAdapter::setItems)
    }

На этом всё. Декор нашего списка готов.

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

Посмотрим как ещё можно применить декораторы.

PageIndicator для горизонтального RecyclerView

Bubble сообщения в чате и scroll bar:

Более сложный кейс — отрисовка фигур, иконок, изменение темы без перезагрузки экрана:


Sticky header

Исходный код с примерами

Заключение


Несмотря на простоту интерфейса ItemDecoration, он позволяет делать сложные вещи со списком без изменения вёрстки. Надеюсь мне удалось показать, что это достаточно мощный инструмент и он достоин вашего внимания. А наша библиотека поможет вам декорировать списки проще.

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

UPD: 06.08.2020 добавлен пример для Sticky header
Surf
Компания

Комментарии 10

    –1
    О
      0
      Было бы круто если показали бы сделать плавующую дату (как у телеграма).
        0
        Да, сделать стикихедеры тоже собираюсь.
          0
          Готово, обновил статью.
            0

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

              0
              Да, все верно, если надо с fade анимацией убирать дату то надо вьюху сверху положить. А анимация стикихедера делается просто, без дополнительной верстки.
          +1

          Когда я делал декораторы, у которых логика зависела от соседних ячеек, то все ломалось при использовании DiffUtils. Например, возьмем Ваш кейс с ячейками a. Теперь через DiffUtils удалим последнюю ячейку в блоке. Поскольку изменилась только одна ячейка (последняя), то для двух предыдущих перерисовка вызвана не будет, от чего вторая ячейка (которая стала последней в блоке) останется без скругленных краев. Нужно в таком случае на любые изменения в списке вызывать invalidateItemDecorations(). Есть ли какой то другой способ?

            0
            Хороший вопрос. Иногда бывают ситуации когда надо дополнительно вызывать invalidateItemDecorations(), чтобы всё корректно отрисовалось. EasyAdapter использует DiffUtill для расчетов и у меня такой кейс не воспроизвелся.
            Вот записал видео
            Чтобы было всё честно, добавляются и удаляются одинаковые элементы, с одинаковыми id.
            www.dropbox.com/s/0k1jibhzx6n6qpr/case_delete_last_add_last.mp4?dl=0
              0

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

                0
                Правда очень странно, у нас таких проблем не было еще.

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

            Самое читаемое