Русский
Русский
English
Статистика
Реклама

Recyclerview

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

05.08.2020 14:08:45 | Автор: admin
Привет, дорогой читатель Хабра. Меня зовут Олег Жило, последние 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://personeltest.ru/away/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* ItemDecorationa.

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

Рисование дивайдеров внутри ячейки плохая практика, так как в итоге получится усложненная вёрстка, на сложных экранах начнутся проблемы с динамическим показом дивайдеров. И поэтому 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:

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

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

Заключение


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

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

Перевод MotionLayout RecyclerView красивые анимированные списки

05.04.2021 18:06:45 | Автор: admin

В этой статье я расскажу и покажу, как создавать красивые анимированные списки на основе RecyclerView и MotionLayout. Аналогичный метод я использовал в одном из своих проектов.

От переводчика: репозиторий автора статьи - https://github.com/mjmanaog/foodbuddy.
Я его форкнул, чтобы перевести. Возможно, кому-то "русская версия" подойдёт больше.

Что такое MotionLayout?

Если вкратце, то MotionLayout это подкласс ConstraintLayout, который позволяет с помощью XML описывать движения и анимацию расположенных на нём элементов. Подробнее в документации и вот здесь с примерами.

Итак, начнём.

Шаг 1: создадим новый проект

Назовём его, как душе угодно. В качестве активити выберем Empty Activity.

Шаг 2: добавим необходимые зависимости

В gradle-файл приложения добавим:

implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta1'

И запустим синхронизацию (Sync Now в правом верхнем углу).

Шаг 3: создадим лэйаут

Наш будущий элемент списка будет выглядеть так:

Элемент списка RecyclerViewЭлемент списка RecyclerView

В папке res/layout создадим файл item_food.

Внутри он выглядит так
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:id="@+id/clMain"    android:layout_width="match_parent"    android:layout_height="wrap_content"    app:layoutDescription="@xml/item_food_scene">    <ImageView        android:id="@+id/ivFood"        android:layout_width="150dp"        android:layout_height="150dp"        android:layout_marginTop="8dp"        android:elevation="10dp"        android:scaleType="fitXY"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent"        app:srcCompat="@drawable/img_salmon_salad" />    <androidx.cardview.widget.CardView        android:id="@+id/cardView"        android:layout_width="match_parent"        android:layout_height="150dp"        android:layout_marginStart="100dp"        android:layout_marginLeft="100dp"        android:layout_marginTop="16dp"        android:layout_marginEnd="16dp"        android:layout_marginRight="16dp"        android:layout_marginBottom="16dp"        app:cardCornerRadius="20dp"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent">        <androidx.constraintlayout.widget.ConstraintLayout            android:layout_width="match_parent"            android:layout_height="match_parent">            <TextView                android:id="@+id/tvTitle"                android:layout_width="0dp"                android:layout_height="wrap_content"                android:layout_marginStart="60dp"                android:layout_marginLeft="60dp"                android:layout_marginTop="24dp"                android:layout_marginEnd="8dp"                android:layout_marginRight="8dp"                android:textSize="18sp"                android:textStyle="bold"                app:layout_constraintEnd_toEndOf="parent"                app:layout_constraintStart_toStartOf="parent"                app:layout_constraintTop_toTopOf="parent"                tools:text="Салат с лососем" />            <TextView                android:id="@+id/tvDescription"                android:layout_width="0dp"                android:layout_height="wrap_content"                android:layout_marginStart="60dp"                android:layout_marginLeft="60dp"                android:layout_marginEnd="16dp"                android:layout_marginRight="8dp"                android:ellipsize="end"                android:maxLines="3"                app:layout_constraintEnd_toEndOf="parent"                app:layout_constraintStart_toStartOf="parent"                app:layout_constraintTop_toBottomOf="@+id/tvTitle"                tools:text="Лосось с овощами  идеальное сочетание для полноценного питания. Простой рецепт блюда, которое содержит в себе богатый набор питательных веществ: белки, жиры и клетчатку." />            <TextView                android:id="@+id/tvCalories"                android:layout_width="wrap_content"                android:layout_height="wrap_content"                android:layout_marginStart="8dp"                android:layout_marginLeft="8dp"                android:layout_marginBottom="16dp"                android:textStyle="bold"                app:layout_constraintBottom_toBottomOf="parent"                app:layout_constraintStart_toEndOf="@+id/imageView6"                tools:text="80 ккал" />            <ImageView                android:id="@+id/imageView6"                android:layout_width="24dp"                android:layout_height="24dp"                android:layout_marginStart="60dp"                android:layout_marginLeft="60dp"                android:layout_marginBottom="16dp"                app:layout_constraintBottom_toBottomOf="parent"                app:layout_constraintStart_toStartOf="parent"                app:srcCompat="@drawable/ic_calories" />            <ImageView                android:id="@+id/imageView7"                android:layout_width="24dp"                android:layout_height="24dp"                android:layout_marginStart="24dp"                android:layout_marginLeft="24dp"                android:layout_marginBottom="16dp"                app:layout_constraintBottom_toBottomOf="parent"                app:layout_constraintStart_toEndOf="@+id/tvCalories"                app:srcCompat="@drawable/ic_star" />            <TextView                android:id="@+id/tvRate"                android:layout_width="wrap_content"                android:layout_height="wrap_content"                android:layout_marginStart="8dp"                android:layout_marginLeft="8dp"                android:layout_marginBottom="16dp"                app:layout_constraintBottom_toBottomOf="parent"                app:layout_constraintStart_toEndOf="@+id/imageView7"                tools:text="4.5" />        </androidx.constraintlayout.widget.ConstraintLayout>    </androidx.cardview.widget.CardView></androidx.constraintlayout.widget.ConstraintLayout>

Шаг 4: преобразуем ConstraintLayout в MotionLayout

Чтобы преобразовать ConstraintLayout в MotionLayout:

  • переключитесь в режим Split или Design;

  • в дереве компонентов (Component Tree) щёлкните правой кнопкой мыши на корневой элемент (в данном случае clMain);

  • в появившемся меню выберите Convert to MotionLayout.

Как преобразовать ConstraintLayout в MotionLayoutКак преобразовать ConstraintLayout в MotionLayout

Теперь мы можем работать с MotionLayout.

Содержимое файла item_food поменялось
<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:id="@+id/clMain"    android:layout_width="match_parent"    android:layout_height="wrap_content"    app:layoutDescription="@xml/item_food_scene">    <ImageView        android:id="@+id/ivFood"        android:layout_width="150dp"        android:layout_height="150dp"        android:layout_marginTop="8dp"        android:elevation="10dp"        android:scaleType="fitXY"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent"        app:srcCompat="@drawable/img_salmon_salad" />    <androidx.cardview.widget.CardView        android:id="@+id/cardView"        android:layout_width="match_parent"        android:layout_height="150dp"        android:layout_marginStart="100dp"        android:layout_marginLeft="100dp"        android:layout_marginTop="16dp"        android:layout_marginEnd="16dp"        android:layout_marginRight="16dp"        android:layout_marginBottom="16dp"        app:cardCornerRadius="20dp"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent">        <androidx.constraintlayout.widget.ConstraintLayout            android:layout_width="match_parent"            android:layout_height="match_parent">            <TextView                android:id="@+id/tvTitle"                android:layout_width="0dp"                android:layout_height="wrap_content"                android:layout_marginStart="60dp"                android:layout_marginLeft="60dp"                android:layout_marginTop="24dp"                android:layout_marginEnd="8dp"                android:layout_marginRight="8dp"                android:textSize="18sp"                android:textStyle="bold"                app:layout_constraintEnd_toEndOf="parent"                app:layout_constraintStart_toStartOf="parent"                app:layout_constraintTop_toTopOf="parent"                tools:text="Салат с лососем" />            <TextView                android:id="@+id/tvDescription"                android:layout_width="0dp"                android:layout_height="wrap_content"                android:layout_marginStart="60dp"                android:layout_marginLeft="60dp"                android:layout_marginEnd="16dp"                android:layout_marginRight="8dp"                android:ellipsize="end"                android:maxLines="3"                app:layout_constraintEnd_toEndOf="parent"                app:layout_constraintStart_toStartOf="parent"                app:layout_constraintTop_toBottomOf="@+id/tvTitle"                tools:text="Лосось с овощами  идеальное сочетание для полноценного питания. Простой рецепт блюда, которое содержит в себе богатый набор питательных веществ: белки, жиры и клетчатку." />            <TextView                android:id="@+id/tvCalories"                android:layout_width="wrap_content"                android:layout_height="wrap_content"                android:layout_marginStart="8dp"                android:layout_marginLeft="8dp"                android:layout_marginBottom="16dp"                android:textStyle="bold"                app:layout_constraintBottom_toBottomOf="parent"                app:layout_constraintStart_toEndOf="@+id/imageView6"                tools:text="80 ккал" />            <ImageView                android:id="@+id/imageView6"                android:layout_width="24dp"                android:layout_height="24dp"                android:layout_marginStart="60dp"                android:layout_marginLeft="60dp"                android:layout_marginBottom="16dp"                app:layout_constraintBottom_toBottomOf="parent"                app:layout_constraintStart_toStartOf="parent"                app:srcCompat="@drawable/ic_calories" />            <ImageView                android:id="@+id/imageView7"                android:layout_width="24dp"                android:layout_height="24dp"                android:layout_marginStart="24dp"                android:layout_marginLeft="24dp"                android:layout_marginBottom="16dp"                app:layout_constraintBottom_toBottomOf="parent"                app:layout_constraintStart_toEndOf="@+id/tvCalories"                app:srcCompat="@drawable/ic_star" />            <TextView                android:id="@+id/tvRate"                android:layout_width="wrap_content"                android:layout_height="wrap_content"                android:layout_marginStart="8dp"                android:layout_marginLeft="8dp"                android:layout_marginBottom="16dp"                app:layout_constraintBottom_toBottomOf="parent"                app:layout_constraintStart_toEndOf="@+id/imageView7"                tools:text="4.5" />        </androidx.constraintlayout.widget.ConstraintLayout>    </androidx.cardview.widget.CardView></androidx.constraintlayout.motion.widget.MotionLayout>

В папке res Студия создала папку xml и положила в неё файл item_food_scene.xml:

Студия предупреждает (Warnings в нижней части экрана), что у элементов ImageView не заполнен тег contentDescription. Можете проигнорировать эти сообщения, а можете добавить в XML-разметке соответствующие теги (для чего они нужны, читайте здесь).

Шаг 5: добавим анимацию на ImageView

  1. В дереве элементов выберите ivFood (ImageView с основной картинкой);

  2. В редакторе MotionLayout выберите end;

  3. У выделенного элемента ivFood выделите правую (End) опорную точку и перетащите её за правую (End) границу родительского элемента;

  4. Картинка должна встать по центру родительского элемента;

  5. Поменяйте значение атрибутов layout_height и layout_width на 300dp.

От переводчика: начальное состояние ImageView (его положение, ширина и высота) осталось без изменений, а его конечное состояние изменилось: он встанет по центру и увеличится в размере в два раза (с 150dp до 300dp).

Шаг 6: посмотрим, что получилось

Чтобы воспроизвести анимацию, которую мы только что настроили:

  1. В редакторе MotionLayout выделите толстую стрелку, которая соединяет прямоугольники с надписями start и end;

  2. В редакторе ниже станет доступным блок Transition;

  3. Нажмите кнопку Play, чтобы воспроизвести анимацию.

Шаг 7: добавим анимацию на CardView

Порядок действий схож:

  1. В дереве компонентов выделите cardView (constraintView с заголовком, описанием, калорийностью и оценкой);

  2. В редакторе MotionLayout выберите end;

  3. Выделите cardView в появившемся разделе ConstraintSet;

  4. В разделе атрибутов элемента перейдите к группе Transforms;

  5. Поменяйте значение атрибута alpha на 0.

От переводчика: у карточки с описанием блюда конечное состояние (end) от начального (start) отличается только значением параметра alpha. В конечном состоянии она будет скрыта (и скрываться она будет плавно).

Шаг 8: добавим обработчик нажатий

Чтобы анимация включалась, надо настроить обработчик нажатий:

  1. В разделе атрибутов под OnClick добавьте новое поле (кнопка +);

  2. В параметра targetId выберите значение ivFood;

  3. Добавьте ещё одно поле;

  4. Для параметра ClickAction выберите значение toggle.

В результате получится такая анимация:

Шаг 9: добавим RecyclerView в activity_main.xml

<?xml version="1.0" encoding="utf-8"?><androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    android:layout_width="match_parent"    android:layout_height="match_parent">    <androidx.recyclerview.widget.RecyclerView        android:id="@+id/rvMain"        android:layout_width="match_parent"        android:layout_height="match_parent"        app:layout_constraintBottom_toBottomOf="parent"        app:layout_constraintEnd_toEndOf="parent"        app:layout_constraintStart_toStartOf="parent"        app:layout_constraintTop_toTopOf="parent" /></androidx.constraintlayout.widget.ConstraintLayout>

Шаг 10: создадим класс и фиктивные данные для примера

package com.mjmanaog.foodbuddy.data.modelimport com.mjmanaog.foodbuddy.Rdata class FoodModel(        val title: String,        val description: String,        val calories: String,        val rate: String,        val imgId: Int)val foodDummyData: ArrayList<FoodModel> = arrayListOf(        FoodModel(                "Салат с лососем",                "Лосось с овощами  идеальное сочетание для полноценного питания. Простой рецепт блюда, которое содержит в себе богатый набор питательных веществ: белки, жиры и клетчатку.",                "80 ккал",                "4.5",                R.drawable.img_salmon_salad        ),        FoodModel(                "Куриная грудка-барбекю",                "От курочки, приготовленной на гриле или запечённой в духовке все всегда в восторге, если только она не сухая или пережаренная.",                "80 ккал",                "4.5",                R.drawable.img_chicken        ),        FoodModel(                "Курица с рисом на пару",                "Приготовление на пару  здоровый метод приготовления пищи. Он сохраняет её аромат, нежность и полезные вещества. К тому же для приготовления блюд не используется масло.",                "80 ккал",                "4.5",                R.drawable.img_chicken_rice        ),        FoodModel(                "Салат Цезарь",                "Зелёный салат из листьев салата ромэн и гренок, заправленный лимонным соком (или соком лайма), оливковым маслом, яйцом, Вустерширским соусом, анчоусами, чесноком, дижонской горчицей, сыром Пармезан и чёрным перцем.",                "80 ккал",                "4.5",                R.drawable.img_salad        ),        FoodModel(                "Просто полезная еда",                "Лосось с овощами  идеальное сочетание для полноценного питания. Простой рецепт блюда, которое содержит в себе богатый набор питательных веществ: белки, жиры и клетчатку.",                "80 ккал",                "4.5",                R.drawable.img_healthy        ))

Шаг 11: создадим адаптер и ViewHolder

Тут ничего экзотического нет. Используем нашу FoodModel

Шаг 12: заполним RecyclerView элементами

class MainActivity : AppCompatActivity() {    private var foodAdapter: FoodAdapter = FoodAdapter()    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        setContentView(R.layout.activity_main)        rvMain.layoutManager = LinearLayoutManager(this, RecyclerView.VERTICAL, false)        rvMain.adapter = foodAdapter        foodAdapter.addAll(foodDummyData)    }}

В результате этих несложных действий получилась такая анимация:

GIF из статьи не стал добавлять, потому что

Она вести 11 Мб.

Ещё кое-что

В файле item_food_scene.xml содержится описание анимации, которую мы настроили. Никто не мешает вам создавать и редактировать анимации в файлах сцены вручную.

Надеюсь, материал из этой статьи кому-то окажется полезным. Будет круто, если вы узнаете из неё что-то новое.

Спасибо за внимание.

Подробнее..

Дайджест интересных материалов для мобильного разработчика 382 (15 21 февраля)

21.02.2021 14:07:08 | Автор: admin
В этом выпуске цвета Swift, переиспользуемый чистый Kotlin, выход первой версии Android 12 и страсти по IDFA, дефекты Qt и бриллиантовый чекаут, секреты маркетинга приложений, игровые боты, знания за 5 минут и многое другое.



Этот дайджест доступен в виде еженедельной рассылки. А ежедневно новости мы рассылаем в Telegram-канале.

iOS

Предотвращаем мерж-конфликты с XcodeGen
Цвета в Swift: UIColor
Распознание блоков текста в iOS-приложении с помощью Vision
Apple начала бороться с иррационально высокими ценами в приложениях?
Забанила ли Apple аналитические SDK? Ээ ну
Взлом нативных двоичных файлов ARM64 для запуска на симуляторе iOS
Погружение в CFRunLoop
Создайте новостное приложение в SwiftUI 2.0 (Combine, API, MVVM & Swift Package Manager)
Используем Charles для переписывания ответов при разработке приложений для iOS
Clubhouse-подобное изображение в профиле на Swift
Создаем анимированные круговые и кольцевые диаграммы в SwiftUI
Создание рулетки на SwiftUI
OnTap: документация по SwiftUI
WatchLayout: круги в UICollectionView
SPAlert: уведомления в стиле Apple

Android

Как писать и переиспользовать код на чистом Kotlin. Заметки Android-разработчика
Как найти подходящую абстракцию для работы со строками в Android
Темы, стили и атрибуты
Вышла превью-версия Android 12
GitHub Actions для Android-разработки
Как мы ускорили запуск приложения Dropbox для Android на 30%
Как изменится дизайн в Android 12
Контрольный список качества приложения
Анти-паттерны RecyclerView
StateFlow с одно- и двусторонним DataBinding-ом на Android
Как на самом деле работает RxJava
Готовим наши приложения к Jetpack Compose
Простое создание параллакса на Jetpack Compose
5 расширений Kotlin, которые сделают ваш Android-код более выразительным
IridescentView: переливающиеся изображения для Android
stackzyr: Jetpack Compose для десктопов

Разработка

Обработка дат притягивает ошибки или 77 дефектов в Qt 6
Запуск топ-приложения в одиночку, бесплатно и без кодинга (ну почти)
Как мы накосячили пока делали Бриллиантовый чекаут 9 месяцев, а планировали 2
1 год с Flutter в продакшне
Тесты должна писать разработка (?)
Опыт разработки первой мобильной игры на Unity или как полностью перевернуть свою жизнь
О поиске утечек памяти в С++/Qt приложениях
Стратегия тестирования краткосрочного проекта
Готовим Большую Фичу на Kotlin Multiplatform. Доклад Яндекса
ZERG что за зверь?
Podlodka #203: платежи
Microsoft открывает Dapr для простого развертывания микросервисов
Задачи с собеседований: 2 в 64 степени
Дизайн приложений: примеры для вдохновения #32
Как сделать инсайты UX-исследований видимыми, прослеживаемыми и увлекательными?
5 вопросов на интервью для выявления выдающихся программистов
Как создать простое шахматное приложение с помощью Flutter
Создавая бэкенд Uber: пошаговое руководство по системному дизайну
5 удивительных преимуществ обмена знаниями в качестве разработчика
Чтение кода это навык
Почему я перестал читать статьи Как стать разработчиком программного обеспечения
Психология дизайна и нейробиология, стоящая за классным UX
Удаленное определение частоты пульса с помощью веб-камеры и 50 строк кода
Как разозлить разработчика
7 обязательных навыков, чтобы стать выдающимся разработчиком

Аналитика, маркетинг и монетизация

Кратко о продуктовых метриках
Маркетологи в мобайле: Денис Нуждин (Пятёрочка Доставка)
Секреты маркетинга приложений для знакомств новое руководство Adjust
Среда совместного программирования Replit получила $20 млн
Photomath получил еще $23 млн.
Post-IDFA Alliance открыл сайт Нет IDFA? Нет проблем
Взрослые в США в 2020 прибавили сразу час цифрового времени
ВКонтакте запустил новый инструмент для автоматизированной рекламы приложений
Отчет Состояние рынка приложений для фитнеса и здоровья 2021
Jigsaw получает $3.7 млн на дейтинг с головоломкой
Uptime: знания за пять минут
Как запустить wellness-стартап на свои деньги, совмещать с постоянной работой и не сойти с ума
Что будет с трекингом мобильных приложений в 2021 году
Новая норма: обучение в приложениях и как добиться успеха в меняющиеся времена
Лучшие маркетинговые метрики для отслеживания показателей роста
Вот почему разработчикам не удается добиться успеха в карьере
Как я занимался маркетингом своей игры, продажи которой за год составили 128 тысяч долларов

AI, Устройства, IoT

Cчетчик газа в Home Assistant без паяльника
Устройство игрового бота: 16-е место в финале Russian AI Cup 2020 (и 5-е после)
Умный дом с нуля своими руками или путешествие длиною в год
Как распознать рукописный текст с помощью ИИ на микроконтроллерах
Часы для обнаружения жестов на основе машинного обучения, ESP8266 и Arduino
Как преобразовать текст в речь с использованием Google Tesseract и Arm NN на Raspberry Pi
Быстрый прототип IIoT-решения на Raspberry PI и Yandex IoT. Часть вторая
Первый опыт с Raspberry Pi или микросервисы для дома
Google сворачивает Swift для TensorFlow

< Предыдущий дайджест. Если у вас есть другие интересные материалы или вы нашли ошибку пришлите, пожалуйста, в почту.
Подробнее..

Легкий DataBinding для Android

22.03.2021 04:10:35 | Автор: admin

Здравствуйте уважаемые читатели. Все мы любим и используем DataBinding, который представила компания Google несколько лет назад, для связи модели данных с вьюшками через ViewModel. В этой статье, хочу поделиться с вами, как можно унифицировать этот процесс с помощью языка Kotlin, и уместить создание адаптеров для RecyclerView (далее RV), ViewPager и ViewPager2 в несколько строчек кода.

Начну с того, что раньше разрабатывали кастомные адаптеры, которые под капотом создавали ViewHolder'ы, и их написание, а тем более поддержка, занимала достаточно большое количество времени. Ниже приведу пример типичного адаптера для RV:

class CustomAdapter(private val dataSet: Array<String>) : RecyclerView.Adapter<CustomAdapter.ViewHolder>() {        class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {    val textView: TextView            init {      textView = view.findViewById(R.id.textView)    }  }       override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): ViewHolder {    val view = LayoutInflater.from(viewGroup.context).inflate(R.layout.text_row_item, viewGroup, false)          return ViewHolder(view)  }         override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {    // Get element from your dataset at this position and replace the    viewHolder.textView.text = dataSet[position]  }        override fun getItemCount() = dataSet.size}

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

Затем появился DataBinding и большую часть по связыванию данных перекладывалась на него, но адаптеры все равно приходилось писать вручную, изменились только методы onCreateViewHolder, где вместо инфлэйтинга через LayoutInflater, использовался DataBindingUtil.inflate, а при создании вьюхолдеров данные связывались непосредственно с самой вьюшкой через ссылку на созданный объект байдинга.

class BindingViewHolder(val binding: ItemTextRowBinding) : RecyclerView.ViewHolder(binding.root)override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder {        val binding = DataBindingUtil.inflate<ItemTextRowBinding>(LayoutInflater.from(parent.context), viewType, parent, false)        val viewHolder = BindingViewHolder(binding)        return viewHolder}override fun onBindViewHolder(holder: BindingViewHolder, position: Int) {      holder.binding.setVariable(BR.item, dataSet[position])}

Выглядит уже лучше, но что если в RV, по прежнему должны отображаться элементы лайаута с разными типами данных, то такая реализация не сильно помогла решить проблему больших адаптеров. И здесь на помощь приходит аннотация BindingAdapter из библиотеки androidx.databinding. С ее помощью, можно создать универсальное решение, которое скрывает реализацию создания адаптера для RV, если использовать вспомогательный объект-конфигуратор DataBindingRecyclerViewConfig, в котором содержится ряд свойств для настройки адаптера.

В результате на свет появилась библиотека, которая называется EasyRecyclerBinding. В нее так же вошли BindingAdapters для ViewPager и ViewPager2. Теперь процесс связывания данных выглядит следующим образом:
1) В лайауте фрагмента, необходимо добавить специальные переменные, которые содержат список отображаемых моделей данных и конфигурацию, указав их атрибутами для RV, - app:items и app:rv_config.

<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>    <variable        name="vm"        type="com.rasalexman.erb.ui.base.ExampleViewModel" />        <variable            name="rvConfig"            type="com.rasalexman.easyrecyclerbinding.DataBindingRecyclerViewConfig" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:id="@+id/main"        android:layout_width="match_parent"        android:layout_height="match_parent">        <androidx.recyclerview.widget.RecyclerView            android:layout_width="0dp"            android:layout_height="0dp"            app:items="@{vm.items}"            app:rv_config="@{rvConfig}"            app:layout_constraintBottom_toBottomOf="parent"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toTopOf="parent"            tools:listitem="@layout/item_recycler"/>    </androidx.constraintlayout.widget.ConstraintLayout></layout>

ViewModel, соответственно, содержит список моделей данных для адаптера, которые должны отображаться в RV, а фрагмент конфигурацию DataBindingRecyclerViewConfig.

// названия пакетов не указаны для простоты примераclass ExampleViewModel : ViewModel() {     val items: MutableLiveData<MutableList<RecyclerItemUI>> = MutableLiveData()}data class RecyclerItemUI(    val id: String,    val title: String)

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

<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>        <variable            name="item"            type="com.rasalexman.erb.models.RecyclerItemUI" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:background="@android:drawable/list_selector_background">        <TextView            android:id="@+id/titleTV"            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:paddingStart="16dp"            android:paddingTop="8dp"            android:paddingEnd="16dp"            android:textColor="@color/black"            android:textSize="18sp"            android:text="@{item.title}"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toTopOf="parent"            tools:text="Hello world" />        <TextView            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:paddingStart="16dp"            android:paddingEnd="16dp"            android:paddingBottom="8dp"            android:textColor="@color/gray"            android:textSize="14sp"            android:text="@{item.id}"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toBottomOf="@+id/titleTV"            tools:text="Hello world" />    </androidx.constraintlayout.widget.ConstraintLayout></layout>

2) Во фрагменте нам нужно получить конфигурацию для адаптера и передать её в отображение через инстанс dataBinding, используя специальную функцию-конструктор createRecyclerConfig<I : Any, BT : ViewDataBinding>, которая создаст и вернет инстанс DataBindingRecyclerViewConfig, указав при этом id лайаута для выбранной модели, и название свойства, к которому будет прикреплена данная модель.

class RecyclerViewExampleFragment : BaseBindingFragment<RvExampleFragmentBinding, ExampleViewModel>() {      override val layoutId: Int get() = R.layout.rv_example_fragment  override val viewModel: ExampleViewModel by viewModels()        override fun initBinding(binding: RvExampleFragmentBinding) {        super.initBinding(binding)        binding.rvConfig = createRecyclerConfig<RecyclerItemUI, ItemRecyclerBinding> {            layoutId = R.layout.item_recycler        itemId = BR.item                    }    }}

Это все, что нужно сделать, чтобы связать данные из ViewModel с отображением списка в RV. Так же при создании адаптера можно назначить слушатели событий для байдинга вьюхолдера, такие как onItemClick,onItemCreate, onItemBind и другие.

А чтобы использовать вьюхолдеры с разными визуальными лайаутами, к которым привязаны свои модели отображения данных, необходимо имплементировать в них специальный интерфейс из библиотеки EasyRecyclerBinding - IBindingModelи переопределить поле layoutResId, - id лайаута, который будет отображаться для этой модели в списке.

data class RecyclerItemUI(    val id: String,    val title: String) : IBindingModel {        override val layoutResId: Int            get() = R.layout.item_recycler}data class RecyclerItemUI2(    val id: String,    val title: String) : IBindingModel {    override val layoutResId: Int        get() = R.layout.item_recycler2}

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

class RecyclerViewExampleFragment : BaseBindingFragment<RvExampleFragmentBinding, RecyclerViewExampleViewModel>() {      override val layoutId: Int get() = R.layout.rv_example_fragment    override val viewModel: RecyclerViewExampleViewModel by viewModels()        override fun initBinding(binding: RvExampleFragmentBinding) {        super.initBinding(binding)        binding.rvConfig = createRecyclerMultiConfig {            itemId = BR.item        }    }}class RecyclerViewExampleViewModel : BasePagesViewModel() {    open val items: MutableLiveData<MutableList<IBindingModel>> = MutableLiveData()}

Таким образом, создание адаптеров для отображения данных в RV, превратилось в простую задачу состоящую из пары строчек кода, где разработчику уже не надо думать о том, как поддерживать, фактически, не нужную часть presentation слоя. И даже, если модель данных изменится, достаточно будет поменять только её отображение, и связать его с новыми данными, не заботясь о поддержке адаптеров.


Аналогичный процесс создания адаптеров для ViewPager и ViewPager2, представлен в примере на github вместе с открытым кодом, ссылку на который, я разместил в конце статьи. В настоящий момент библиотека еще дорабатывается, и хочется получить адекватный фидбек, и пожелания по дальнейшему ее развитию. Так же в неё вошли вспомогательные функции для удобного создания байдинга, в том числе в связке с ViewModel. (LayoutInflater.createBinding, Fragment.createBindingWithViewModel, etc)

Спасибо, что дочитали до конца. Приятного кодинга и хорошего настроения)

Подробнее..

Простой вариант разношерстного recycler view на шаблоне Посетитель

07.04.2021 16:22:59 | Автор: admin

Прошло пол года, как я с паскаля перекатился на kotlin и влюбился в android-разработку, и вот уже разрешаю себе публично лезть со своими идеями в чужой монастырь. Но причина на то есть. Понаблюдав в профильных чатах за тем, какие чаще всего возникают вопросы у android-разработчиков, и не только у новичков, я понял, что в большинстве случаев, когда человек сталкивается с ошибкой, которую не может понять, как не может понять объяснение коллег из чата или их наводящие вопросы, причиной является бездумное использование готовых кусков кода или библиотек. Однако, полагаясь на готовые примеры кода, которые у них не работают (а в этой сфере код, написанный больше года назад, по умолчанию требует обновления или вообще переработки, и это касается кода со stack overflow, библиотечных гайдов и даже гайдов от самого Google), они не понимают причин возникающих ошибок или же отличающегося поведения, поскольку полагаются на библиотеку как китайскую комнату, не пытаясь разобраться в её архитектуре и принципах работы.

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


Изучая архитектурные шаблоны android-разработки, я приучил себя в первую очередь искать ответы на сервере Google developer guides. Но иногда там, особенно в обучающих codelabs, приводятся примеры кода больше упрощенного, чем рассчитанного на универсальность, чистоту и расширяемость.

В данном случае у меня возникла потребность использовать модный recycler view для отображения списка элементов с разной внутренней разметкой и логикой. На такой идее строятся все современные приложения - от мессенджеров и лент социальных сетей до банковских приложений. К тому же комбинирование на лету с использованием реактивного подхода разных визуальных элементов списка recycler view вместо ручной верстки разметки является мостиком в мир декларативно-функционального ui, который нам предлагают в Jetpack Compose, и на который рано или поздно Google мягко предложит переходить.

Codelab, посвященный включению в список recycler view элемента с другой разметкой, строится на оборачивании элемента списка внутрь sealed класса. Но это не главный недостаток. Главный, на мой взгляд,- помещение всего кода, обрабатывающего разные элементы списка, внутрь класса самого адаптера. Это заставит в будущем расти код адаптера как снежный ком, нарушая как принцип открытости/закрытости, так и принцип единственной ответственности (при желании, можно найти нарушения каждой буквы из акронима SOLID, но поэтому их и объединили).

Другим существенным минусом является то, что Google предложил вынести свойство id из data-классов в качестве дискриминатора двух типов элементов: для заголовка id будет равен Long.MIN_VALUE, а для данных id будет транзитом переходить из data-класса. И здесь полностью закрыта щелочка для дальнейшего расширения: у вас или data-класс, который для адаптера будет всегда одинаков, или заголовок. Вся архитектура могучего recycler view мгновенно сжалась до всего лишь двух вариантов.

Решением проблемы можно считать использование готовых библиотек. Я из самых актуальных и наиболее распространенных нашел adapter delegates, groupie и epoxy. По ним по всем написаны многие статьи как здесь, так и там. Самый базовый подход, который я собираюсь сейчас изложить, наиболее близко воплощен в первой библиотеке. Группи и эпокси гораздо мощнее, универсальнее, но при этом сложнее внутри и станут сложнее снаружи, если вдруг разработчику захочется использовать всю их мощь.

Любая библиотека всегда таит в себе две беды:

  • вам бывает лень разбираться в ее устройстве, в итоге вы не осознаете, что используете библиотеку всего лишь на 10%, прямо как мозг у некоторых млекопитающих;

  • вы зависите от библиотечных классов: вам или надо наследовать ваши данные от них, или еще как-то ломать свои представления об идеальных data-классах и их движении.

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

Но также не беда, если вам захочется понять принцип, на котором все перечисленные выше библиотеки основаны, а поняв, попробовать реализовать свой собственный способ работы с recycler view с именно той функциональностью, которая нужна вам в вашем конкретном приложении. Иногда пара своих дополнительных классов с десятком строк кода в каждом вместо зависимости от библиотеки делает то же самое, при этом не перегружая код и оставляя вам гораздо больше свободы.

Так вот, в самом широком смысле в этих библиотеках применяется паттерн Посетитель с менеджером.

Адаптер recycler view, то есть обычный ListAdapter, уже имеет все необходимые методы, которые подталкивают к использованию Посетителя:

  • getItemType - функция, которая должна возвращать тип элемента (поскольку тип в данном случае всего лишь целое число, Google рекомендует использовать более понятные константы);

  • onCreateViewHolder - функция, которая возвращает ViewHolder такого класса, который реализован для требуемого типа элемента (тип передается в функцию параметром с помощью предыдущей функции);

  • onBindViewHolder - функция, которая осуществляет привязку конкретного элемента в списке (передается по номеру) и конкретного ViewHolder, который создан и возвращен предыдущей функцией.

С учетом специфики recycler view всегда следует помнить, что после пролистывания экрана для следующих элементов recycler view использует уже созданные места, поэтому если не хочется определять соответствие типа каждый раз, нужно внимательно следить за тем, что и в какие классы передается, а также ответственно отнестись к реализации DiffUtil-коллбэка.

Если вы реализуете стандартный шаблон DiffCallback,
class BaseDiffCallback : DiffUtil.ItemCallback<HasStringId>() {    override fun areItemsTheSame(oldItem: HasStringId, newItem: HasStringId): Boolean = oldItem.id == newItem.id    override fun areContentsTheSame(oldItem: HasStringId, newItem: HasStringId): Boolean = oldItem == newItem}

то всегда помните, что areContentsTheSame вызывается только тогда, когда areItemsTheSame возвращает true. В моем примере классы реализуют интерфейс HasStringId, в котором есть id типа String и метод equals, что позволяет использовать data-классы как для моделей данных, так и для моделей слоя view. Data-классы с данными из сети всегда имеют уникальный id, поэтому их отличие DiffUtil определяет максимально быстро, а для вспомогательных ui-классов с одинаковыми id вызываются оба метода.

Чтобы управлять набором посетителей, введем такое понятие, как менеджер. Это будет класс, реализующий следующий интерфейс:

interface ViewHoldersManager {    fun registerViewHolder(itemType: Int, viewHolder: ViewHolderVisitor)    fun getItemType(item: Any): Int    fun getViewHolder(itemType: Int): ViewHolderVisitor}

И определим для целей тестового примера набор типов для recycler view:

object ItemTypes {    const val UNKNOWN = -1    const val HEADER = 0    const val TWO_STRINGS = 1    const val ONE_LINE_STRINGS = 2    const val CARD = 3}

Собственно менеджер и будет тем "делегатом" в терминологии adapter delegates, которому адаптер делегирует функции по определению типа для отрисовки текущего элемента. Для этого в менеджере должны быть зарегистрированы все необходимые классы вью холдеров.

Я буду использовать hilt и data binding, поскольку с их помощью очень легко решить второстепенные задачи: внедрить менеджера вью холдеров и упростить отображение данных на ui. Бонусом будет точное знание того места, где именно и в какой момент инициализируется менеджер вью холдеров, а также какие вью холдеры регистрируются в нем:

@Module@InstallIn(FragmentComponent::class)object DiModule {    @Provides    @FragmentScoped    fun provideAdaptersManager(): ViewHoldersManager = ViewHoldersManagerImpl().apply {        registerViewHolder(ItemTypes.HEADER, HeaderViewHolder())        registerViewHolder(ItemTypes.ONE_LINE_STRINGS, OneLine2ViewHolder())        registerViewHolder(ItemTypes.TWO_STRINGS, TwoStringsViewHolder())        registerViewHolder(ItemTypes.CARD, CardViewHolder())    }}

Добавим верстку для всех элементов:

Сard item
<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>        <variable name="card" type="ru.alexmaryin.recycleronvisitor.data.ui_models.CardItem" />    </data>    <androidx.cardview.widget.CardView xmlns:card_view="http://personeltest.ru/away/schemas.android.com/apk/res-auto"        android:id="@+id/card_view"        android:layout_width="match_parent"        android:layout_height="200dp"        android:layout_margin="8dp"        card_view:cardBackgroundColor="@color/cardview_shadow_end_color"        card_view:cardCornerRadius="15dp">        <ImageView            android:id="@+id/card_background_image"            android:layout_width="match_parent"            android:layout_height="match_parent"            android:layout_gravity="center"            android:scaleType="centerCrop"            tools:ignore="ContentDescription"            tools:src="@android:mipmap/sym_def_app_icon" />        <LinearLayout            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:layout_gravity="bottom"            android:background="@android:drawable/screen_background_dark_transparent"            android:orientation="vertical"            android:padding="16dp">            <TextView                android:id="@+id/card_title"                android:layout_width="match_parent"                android:layout_height="wrap_content"                android:ellipsize="end"                android:maxLines="1"                android:paddingTop="8dp"                android:paddingBottom="8dp"                android:textAllCaps="true"                android:textColor="#FFFFFF"                android:textStyle="bold"                tools:text="Cart title"                android:text="@{card.title}"/>            <TextView                android:id="@+id/txt_discription"                android:layout_width="match_parent"                android:layout_height="wrap_content"                android:ellipsize="end"                android:maxLines="2"                android:textColor="#FFFFFF"                tools:text="this is a simple discription with losts of text lorem ipsum dolor sit amet,            consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."                android:text="@{card.description}"/>        </LinearLayout>    </androidx.cardview.widget.CardView></layout>
One line item
<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools">    <data>        <variable name="model" type="ru.alexmaryin.recycleronvisitor.data.ui_models.OneLineItem2" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:layout_width="match_parent"        android:layout_height="wrap_content">        <TextView            android:id="@+id/text1"            android:layout_width="0dp"            android:layout_height="wrap_content"            android:paddingStart="8dp"            android:text="@{model.left}"            android:textAlignment="textEnd"            android:textAppearance="?attr/textAppearanceListItem"            android:textColor="@color/cardview_dark_background"            app:layout_constraintEnd_toStartOf="@+id/divider"            app:layout_constraintHorizontal_bias="0.5"            app:layout_constraintStart_toStartOf="parent"            app:layout_constraintTop_toTopOf="parent"            tools:ignore="RtlSymmetry,TextContrastCheck"            tools:text="Left text" />        <ImageView            android:id="@+id/divider"            android:layout_width="wrap_content"            android:layout_height="wrap_content"            android:alpha="0.6"            android:padding="5dp"            android:scaleType="center"            android:scaleX="0.5"            android:scaleY="0.9"            android:src="@drawable/ic_outline_waves_24"            android:visibility="visible"            app:layout_constraintBottom_toBottomOf="@+id/text1"            app:layout_constraintEnd_toStartOf="@+id/text2"            app:layout_constraintHorizontal_bias="0.5"            app:layout_constraintStart_toEndOf="@+id/text1"            app:layout_constraintTop_toTopOf="@+id/text1"            app:srcCompat="@drawable/ic_outline_waves_24"            tools:ignore="ContentDescription"            tools:visibility="visible" />        <TextView            android:id="@id/text2"            android:layout_width="0dp"            android:layout_height="wrap_content"            android:paddingEnd="8dp"            android:text="@{model.right}"            android:textAppearance="?attr/textAppearanceListItem"            app:layout_constraintBottom_toBottomOf="@+id/divider"            app:layout_constraintEnd_toEndOf="parent"            app:layout_constraintHorizontal_bias="0.5"            app:layout_constraintStart_toEndOf="@+id/divider"            app:layout_constraintTop_toTopOf="@+id/divider"            tools:ignore="RtlSymmetry"            tools:text="Right text" />    </androidx.constraintlayout.widget.ConstraintLayout></layout>
Two line item
<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto">    <data>        <variable name="model" type="ru.alexmaryin.recycleronvisitor.data.ui_models.TwoStringsItem" />    </data>    <androidx.constraintlayout.widget.ConstraintLayout        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:minHeight="?attr/listPreferredItemHeight"        android:mode="twoLine"        android:paddingStart="?attr/listPreferredItemPaddingStart"        android:paddingEnd="?attr/listPreferredItemPaddingEnd">        <TextView            android:id="@+id/text1"            android:layout_width="0dp"            android:layout_height="wrap_content"            android:layout_marginTop="8dp"            android:text="@{model.caption}"            app:layout_constraintTop_toTopOf="parent"            app:layout_constraintStart_toStartOf="parent"            android:textAppearance="?attr/textAppearanceListItem" />        <TextView            android:id="@id/text2"            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:text="@{model.details}"            app:layout_constraintTop_toBottomOf="@id/text1"            app:layout_constraintStart_toStartOf="parent"            android:textAppearance="?attr/textAppearanceListItemSecondary" />    </androidx.constraintlayout.widget.ConstraintLayout></layout>
Header item
<?xml version="1.0" encoding="utf-8"?><layout xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android">    <data>        <variable            name="headerItem"            type="ru.alexmaryin.recycleronvisitor.data.ui_models.RecyclerHeader" />    </data>    <TextView        style="@style/regularText"        android:id="@+id/header"        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:background="#591976D2"        android:textAlignment="center"        android:textStyle="italic"        android:text="@{headerItem.text}"/></layout>

Вью холдер будет классом, реализующим простой интерфейс Посетителя:

interface ViewHolderVisitor {    val layout: Int    fun acceptBinding(item: Any): Boolean    fun bind(binding: ViewDataBinding, item: Any, clickListener: AdapterClickListenerById)}

Здесь два стандартных для Посетителя метода (классически они называются acceptVisitor и execute, однако мы же пишем не абстрактного посетителя для реализации паттерна в вакууме, а весьма конкретное его приложение) - acceptBinding и bind, а также свойство layout, в которое конкретные вью холдеры будут записывать ссылку на ресурс своей разметки.

Роль функции accept заключается в следующем: когда адаптер просит (или требует) от менеджера выдать ему соответствие типа элемента тому объекту, который должен быть отрисован, менеджер пробегается по всем зарегистрировавшимся вью холдерам, вызывая их метод accept, и выдает тип первого, который вернет true. Таким образом, ни адаптер, ни менеджер не знают ничего о внутреннем устройстве и даже классе самих элементов, что позволяет бесконечно увеличивать их количество и изменять как угодно. Единственное требование - зарегистрировавшийся вью холдер должен честно признаваться (accept = true), что готов забиндить какой-то элемент, тип которого он указал при регистрации.

Реализация как менеджера, так и конкретных вью холдеров будет настолько короткой, насколько это возможно. Практически везде однострочные функции:

class ViewHoldersManagerImpl : ViewHoldersManager {    private val holdersMap = emptyMap<Int, ViewHolderVisitor>().toMutableMap()    override fun registerViewHolder(itemType: Int, viewHolder: ViewHolderVisitor) {        holdersMap += itemType to viewHolder    }    override fun getItemType(item: Any): Int {        holdersMap.forEach { (itemType, holder) ->             if(holder.acceptBinding(item)) return itemType        }        return ItemTypes.UNKNOWN    }    override fun getViewHolder(itemType: Int) = holdersMap[itemType] ?: throw TypeCastException("Unknown recycler item type!")}

И для примера вью холдер карточки (другие реализации очевидны и практически аналогичны):

class CardViewHolder : ViewHolderVisitor {      override val layout: Int = R.layout.card_item    override fun acceptBinding(item: Any): Boolean = item is CardItem    override fun bind(binding: ViewDataBinding, item: Any, clickListener: AdapterClickListenerById) {        with(binding as CardItemBinding) {            card = item as CardItem            Picasso.get().load(item.image).into(cardBackgroundImage)        }    }}

Не стоит бояться операторов явного приведения типов as в коде. Во-первых, реализуя интерфейс, вы заключаете определенный контракт: если функция accept согласна с тем, что Посетитель работает с элементами класса CardItem, в метод bind совершенно точно будет передан объект только этого класса и никакого другого. Это же касается и разметки: если вы однозначно определяете имя ресурса разметки в свойстве layout, именно для этой разметки в свойство binding будет передаваться сгенерированный data binding класс. Ну и во-вторых, если бы это не было безопасно, разве линтер idea или android studio не разразились ли громкими воплями на всю сборку?

Что касается самого главного, адаптера для recycler view,- он единственный и универсальный, поскольку все функции по различению разных элементов делегирует менеджеру, а тот в свою очередь выбирает одного единственного холдера, согласного посетить и прибиндить намертво элемент к экрану смартфона:

class BaseListAdapter(    private val clickListener: AdapterClickListenerById,    private val viewHoldersManager: ViewHoldersManager) : ListAdapter<HasStringId, BaseListAdapter.DataViewHolder>(BaseDiffCallback()) {    inner class DataViewHolder(        private val binding: ViewDataBinding,        private val holder: ViewHolderVisitor    ) : RecyclerView.ViewHolder(binding.root) {        fun bind(item: HasStringId, clickListener: AdapterClickListenerById) =            holder.bind(binding, item, clickListener)    }    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DataViewHolder =        LayoutInflater.from(parent.context).run {            val holder = viewHoldersManager.getViewHolder(viewType)            DataViewHolder(DataBindingUtil.inflate(this, holder.layout, parent, false), holder)        }    override fun onBindViewHolder(holder: DataViewHolder, position: Int) = holder.bind(getItem(position), clickListener)    override fun getItemViewType(position: Int): Int = viewHoldersManager.getItemType(getItem(position))}

Сам адаптер создается и наполняется элементами уже в слое view, в данном случае во фрагменте таким нехитрым способом:

// где-то выше во фрагменте:// private val viewModel: MainViewModel by viewModels()// private lateinit var recycler: RecyclerView// @Inject lateinit var viewHoldersManager: ViewHoldersManager// private val items = mutableListOf<HasStringId>()override fun onViewCreated(view: View, savedInstanceState: Bundle?) {        recycler = requireActivity().findViewById(R.id.recycller)        val itemsAdapter = BaseListAdapter(AdapterClickListenerById {}, viewHoldersManager)        itemsAdapter.submitList(items)        recycler.apply {            layoutManager = LinearLayoutManager(requireContext())            addItemDecoration(DividerItemDecoration(requireContext(), (layoutManager as LinearLayoutManager).orientation))            adapter = itemsAdapter        }        populateRecycler()    }private fun populateRecycler() {     lifecycleScope.launch {        viewModel.getItems().flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)           .collect { items.add(it) }     }   }

Благодаря одному "лишнему" классу менеджера вью холдеров и паре интерфейсов мы получили простой, но очень функциональный recycler view с возможностью безболезненного расширения. К тому же в противовес библиотекам у нас остались следующие преимущества:

  • нет необходимости наследовать классы данных от библиотечных супер-классов;

  • нет необходимости оборачивать разные элементы в sealed класс;

  • один и тот же data-класс можно использовать для сериализации/десериализации данных из интернета, сохранения в локальной базе и в качестве модели для view или data биндинга;

  • изменения классов данных и вью-холдеров не влекут изменений кода адаптера;

  • все внутренности элементов инкапсулированы от адаптера, законы SOLID выполняются;

  • отсутствует избыточная функциональность, неизбежно приносимая библиотеками (YAGNI).

Разумеется, моя реализация еще имеет пути для улучшения и расширения. Можно, как в groupie добавить группировку элементов и их визуальное сворачивание. Можно отказаться от data binding или дополнить адаптер вариантами для view binding или обычного инфлейта разметки со всеми любимыми findViewById во вью холдерах. И тогда код превратится в ту же самую библиотеку, которых уже вон сколько и так. Для моих же конкретных целей на тот момент, когда возникла необходимость, варианта с простым Посетителем более, чем достаточно:

Прошу не судить строго, поскольку в мире android это мое первое появление на свет. Полноценный код примера из текста статьи будет доступен в репозитории github.

Подробнее..

Из песочницы Создание сложного списка элементов за 20 минут в Android на базе Groupie

16.10.2020 16:14:58 | Автор: admin

Списки являются основным способом представления различного контента в мобильных приложениях. Будь то социальная сеть, приложение для чтения книг или интернет-магазин, в большинстве таких приложений встречаются списки с разными видами ячеек, разного уровня вложенности. Самый наглядный пример, известный любому Android-разработчику это приложение Google Play, начальный экран которого является сложным списком с множеством вложенных элементов:


image

Естественно, чтобы поддержать такое разнообразие контента и при этом сохранить оптимальную производительность для такой задачи лучше всего использовать RecyclerView. Как создать список, поддерживающий разные виды ячеек, которые в свою очередь могут тоже содержать вложенные списки за 20 минут, я покажу на примере в этой статье.


В конце у вас получится вот такой список:


image


Итак, задача: создать список для отображения различного вида контента, при этом каждая категория, то есть ячейка списка может содержать неограниченное количество более мелких ячеек и иметь горизонтальный скрол. Звучит сложно? Если вы думаете что это сложная задача, над которой вам нужно будет работать всю неделю, то спешу вас обрадовать, мы сделаем такой список примерно на полчаса.


Подходов к решению такой задачи, множество, но суть решения одна здесь необходимо использовать RecyclerView с различными типами ячеек, в которых также находится RecyclerView для возможности горизонтального скролла неограниченного количества ячеек. Можно использовать как стандартный подход, в котором необходимо будет создать adapter для каждого из списков, ViewHolders для разного типа ячеек и так далее. А можно использовать более быстрый подход без множества похожего кода на базе библиотеки Groupie


Groupie is a simple, flexible library for complex RecyclerView layouts.

Это простая и в тоже время мощная библиотека для упрощения построения списков на базе RecyclerView, которая заметно ускорит разработку сложных списков как в примерах выше.
Безусловно, все что мы видели можно сделать и без этой библиотеки, тем более совсем недавно в Android появился MergeAdapter (если будет интересно, то в следующей статье напишу и про него). Но так или иначе вы столкнётесь с недостатками стандартного подхода, описанного в той же статье. Поэтому, сегодня мы попробуем новый подход, ускоряющий разработку, избавляющий от написания бойлерплейт-кода и соответсвующий принципам SOLID.


Если кратко, то алгоритм действий выглядит следующим:


  • Создаём проект. Добавляем нужные зависимости.
  • Определяем нужные ячейки. Создаём layouts для отображения UI
  • Соединяем ячейки с адаптером RecyclerView и наслаждаемся результатом.

Создание проекта и добавление библиотек.


Для создания списка как в примере на картинке нам понадобится 4 библиотеки: RecyclerView, CardView, Picasso (для отображения картинок) и Groupie. Добавим всё это в build.gradle(app):


implementation 'com.xwray:groupie:2.8.0'implementation 'com.xwray:groupie-kotlin-android-extensions:2.8.0'implementation "androidx.recyclerview:recyclerview:1.1.0"implementation 'com.squareup.picasso:picasso:2.71828'implementation 'androidx.cardview:cardview:1.0.0' 

Кроме этого, добавьте в build.gradle в блок android


androidExtensions {    experimental = true}

Нажмите Sync Now для скачивания необходимых зависимостей.


Создание ячеек для отображения контента


Для отображения списка нам понадобится 3 типа ячеек:


  • Общая ячейка контейнер для отображения вложенного списка. Обозначена красным прямоугольником.
  • Ячейка внутри основной ячейки для отображения информации о фильме. Такие ячейки выделены синим прямоугольником. Они находятся внутри основной ячейки в RecyclerView c горизонтальным скролом.
  • Квадратная ячейка для отображения обложек игр. Выделена зелёным цветом.
    image

Создание главной ячейки с вложенным RecyclerView


Вначале создадим общую ячейку с вложенным RecyclerView для отображения более мелких ячеек.
image


Вёрстка такой ячейки будет состоять из CardView с LinearLayout для отображения названия, описания и RecyclerView для отображения внутренних ячеек.


<?xml version="1.0" encoding="utf-8"?><androidx.cardview.widget.CardView xmlns:android="http://personeltest.ru/away/schemas.android.com/apk/res/android"    xmlns:app="http://personeltest.ru/away/schemas.android.com/apk/res-auto"    xmlns:tools="http://personeltest.ru/away/schemas.android.com/tools"    android:id="@+id/main_content_container"    android:layout_width="match_parent"    android:layout_height="wrap_content"    android:layout_margin="8dp"    android:background="#FFFFFF"    app:cardCornerRadius="8dp">    <LinearLayout        android:layout_width="match_parent"        android:layout_height="wrap_content"        android:background="#FFFFFF"        android:orientation="vertical">        <TextView            android:id="@+id/title_text_view"            android:layout_width="wrap_content"            android:layout_height="wrap_content"            android:layout_marginStart="16dp"            android:layout_marginTop="16dp"            android:textSize="18sp"            android:textStyle="bold"            tools:text="Заголовок карточки" />        <TextView            android:id="@+id/description_text_view"            android:layout_width="wrap_content"            android:layout_height="wrap_content"            android:layout_marginStart="16dp"            android:layout_marginEnd="16dp"            android:textSize="12sp"            tools:text="Описание карточки" />        <androidx.recyclerview.widget.RecyclerView            android:id="@+id/items_container"            android:layout_width="match_parent"            android:layout_height="wrap_content"            android:layout_marginStart="12dp"            android:layout_marginTop="16dp"            android:layout_marginEnd="12dp"            android:layout_marginBottom="16dp"            android:orientation="horizontal"            android:visibility="visible"            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"            tools:ignore="RtlSymmetry" />    </LinearLayout></androidx.cardview.widget.CardView>

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


class MainCardContainer(    private val title: String? = "",    private val description: String? = "",    private val onClick: (url: String) -> Unit,    private val items: List<Item>) : Item() {    override fun getLayout() = R.layout.item_card    override fun bind(viewHolder: GroupieViewHolder, position: Int) {        viewHolder.title_text_view.text = title        viewHolder.description_text_view.text = description        viewHolder.items_container.adapter =            GroupAdapter<GroupieViewHolder>().apply { addAll(items) }    }}

Каждая ячейка при использовании Groupie должна быть наследником от абстрактного класса Item. Для этого необходимо переопределить всего 2 метода getLayout() и bind(). То есть для создания ячейки вам нужно указать layout который будет использоваться для отображения UI и дописать логику формирования данных для этой ячейки и всё! Теперь не нужно писать однотипные адаптеры для разных ячеек или комбинировать множество разных типов ячеек в одном адаптере, нарушая принципы SOLID. Ну или выдумывать базовые классы для ячеек, только для того, чтобы можно было переиспользовать один и тот же адаптер. C Groupie для каждой ячейки вам необходимо создать свой класс, и описать в нем UI!


В данном пример мы будем использовать одну общую ячейку, которая на вход принимает название, описание и список других Item, то есть ячеек, которые и будут наполнять вложенный в эту ячейку RecyclerView. Самое интересное тут, пожалуй вот эта строчка:


viewHolder.items_container.adapter =GroupAdapter<GroupieViewHolder>().apply { addAll(items) }

То есть для RecyclerView который внутри этой ячейки, необходимо добавить общий GroupAdapter и наполнить его ячейками, которые являются наследниками Item.


Общий контейнер готов, теперь осталось сверстать частные ячейки для каждого типа контента.
Их будет 2:


  • Ячейка для фильма с названием фильма
  • Квадратная ячейка с обложкой игры

Ячейка для фильма


Ячейка для фильма, также должна быть наследником Item и должна реализовать 2 метода:


class MovieItem(private val content: MovieContent) : Item() {    override fun getLayout() = R.layout.item_with_text    override fun bind(viewHolder: GroupieViewHolder, position: Int) {        viewHolder.description.text = content.title        Picasso.get()            .load(content.url)            .into(viewHolder.image_preview)    }}

Верстка достаточно простая и код можно посмотреть в проекте на GitHub.


Квадратная ячейка для отображения обложки игры


Эта ячейка тоже является достаточно простой, поэтому лучше посмотрите код проекта.


Все вместе. Соединяем все ячейки вместе


Для создания списка теперь нужно создать ячейки с контентом и передать их в адаптер RecyclerView. Для создания ячеек были созданы 2 метода getPopularMovies() и getPopularGames() которые возвращают ячейки типа Item.


private fun getPopularMovies(): Item {    return MainCardContainer(        "Список фильмов", "Лучшие фильмы", ::onItemClick,        listOf(            MovieItem(                MovieContent(                    "Джокер",                    "https://upload.wikimedia.....jpg"                )            ),            MovieItem(                MovieContent(                    "Бойцовский клуб",                    "https://upload.wikimedia.org......jpg"                )            )        )    )}

Каждый из методов возвращает 1 ячейку MainCardContainer которой передаётся в качестве аргумента список ячеек уже с контентом для вложенного RecyclerView. Например, для ячейки которая отображает список фильмов нужно указать список ячеек MovieItem. Для второй ячейки, которая отображает список игр мы создадим также метод, который создаст основную общую ячейку и передаст ячейки с играми.


В итоге создание списка теперь будет выглядеть так:


override fun onCreate(savedInstanceState: Bundle?) {    super.onCreate(savedInstanceState)    setContentView(R.layout.activity_main)    val movies = listOf(getPopularMovies(), getPopularGames())    items_container.adapter = GroupAdapter<GroupieViewHolder>().apply { addAll(movies) }}

Последняя строка как раз использует GroupAdapter в который мы можем передать любые ячейки, которые являются наследниками Item.


Ну вот и всё! Буквально за 20 минут мы создали сложный список для отображения различного типа контента с вложенным горизонтальным списком! Сравните такой подход с традиционным и сделайте выводы сами! Абсолютно точно, такой подход сэкономит вам время на разработку подобных UI компонентов и избавит от кучи бесполезного кода. А в телеграм-канале @android_school_ru вы сможете найти ещё больше интересных туториалов и бесплатных мини-курсов.

Подробнее..

Перевод Кастомный ItemDecoration для RecyclerView

17.12.2020 00:16:58 | Автор: admin

Предисловие переводчика:

Оригинальная статья содержит GIF-анимации с демонстрацией работы кода (т. е. результат). К сожалению, мне не удалось вставить их в статью, т. к. я использовал новый редактор от Хабра, а он очень криво работает с изображениями :) Для просмотра анимаций, вы можете перейти к оригинальной статье

Как-то раз мне нужно было создать собственный ItemDecoration, и я обнаружил, что в Интернете. почти нет ответов на этот вопрос. Надеюсь, что эта статья будет кому-нибудь полезна.

  1. Простой ItemDecoration

  2. Кастомный ItemDecoration

    1. ItemDecorationоснованный на позиции

    2. ItemDecorationбез отрисовки после последнего списка

    3. ItemDecorationоснованный на типе View

1. Простой ItemDecoration : DividerItemDecoration

Основной вариант использования может быть реализован с помощью DividerItemDecorationпредоставляемый Android-ом.

DividerItemDecoration(Context context, int orientation)

Создает разделитель RecyclerView.ItemDecorationкоторый можно использовать с LinearLayoutManager.

@param context[]будет использоваться для доступа к ресурсам.

@param orientation[]должен быть #HORIZONTAL или #VERTICAL.

Что вам нужно, так это установить правильную ориентацию, а затем предоставить drawable-ресурс, используемый для разделения каждого элемента.

recyclerView.addItemDecoration(    DividerItemDecoration(context, LinearLayoutManager.HORIZONTAL)        .apply { setDrawable(myDrawable) })
Простой DividerItemDecorationПростой DividerItemDecoration

Преимущества

  • 4 строчки кода

  • Предоставляется самим Android

  • Прост в использовании

Недостатки

  • Как было упомянуто: ограниченные варианты использования - в качестве разделителя пустым пространством или линией.

  • Ограничение при использовании и "бесконечной" прокруткой (или пагинацией): декоратор будет отрисован для каждого элемента, в том числе и последнего. В большинстве случаев мы этого не хотим.

2. Custom ItemDecoration

Для лучшего контроля и более сложных вещей мы расширим RecyclerView.ItemDecoration.

Нам доступны 3 метода:

  • getItemOffsets используется для определения расстояния между элементами.

  • onDraw используется для отрисовки в пространстве между элементами.

  • onDrawOver то же, что и onDraw, только вызывается после того, как сам элемент отрисован.

? Cм. официальную документацию: getItemOffsets, onDraw, onDrawOver.

? Обратите внимание, что в примерах используется горизонтальная ориентация, но то же самое можно сделать и с вертикальной.

2.1. Decoration в зависимости от позиции элемента

Для начала простой пример: давайте украсим элементы с нечетной позицией в адаптере.

Кастомный ItemDecoration на основе позиции ViewКастомный ItemDecoration на основе позиции View

Главный метод здесь здесь parent.getChildAdapterPosition (view)

Он возвращает позицию переданной View в адаптере. См. полную документацию.


Не путать с дочерними позициями родителей (parent.children - это элементы, отображаемые на экране).

Не забудьте обработать случай с RecyclerView.NO_POSITION, т. к.getChildAdapterPosition может вернуть -1.

import android.graphics.Canvasimport android.graphics.Rectimport android.graphics.drawable.Drawableimport android.view.Viewimport androidx.core.view.childrenimport androidx.recyclerview.widget.RecyclerViewclass CustomPositionItemDecoration(private val dividerDrawable: Drawable) :    RecyclerView.ItemDecoration() {    override fun getItemOffsets(rect: Rect, view: View, parent: RecyclerView, s: RecyclerView.State) {        val position = parent.getChildAdapterPosition(view)            .let { if (it == RecyclerView.NO_POSITION) return else it }        rect.right =            if (position % 2 == 0) 2            else dividerDrawable.intrinsicWidth    }    override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {        parent.children            .forEach { view ->                val position = parent.getChildAdapterPosition(view)                    .let { if (it == RecyclerView.NO_POSITION) return else it }                if (position % 2 != 0) {                    val left = view.right                    val top = parent.paddingTop                    val right = left + dividerDrawable.intrinsicWidth                    val bottom = top + dividerDrawable.intrinsicHeight - parent.paddingBottom                    dividerDrawable.bounds = Rect(left, top, right, bottom)                    dividerDrawable.draw(canvas)                }            }    }}

2.2. Удаляем ItemDecoration в конце списка

Это наиболее распространенный вариант использования на StackOverflow, о котором вы спрашиваете. Это кастомный ItemDecoration, основанный на позиции элемента в адаптере.

Пользовательское оформление DividerItemDecoration, за исключением последнего элементаПользовательское оформление DividerItemDecoration, за исключением последнего элемента

Исключим последний элемент, добавивif (childAdapterPosition == adapter.itemCount-1).

Нужно помнить, чтоparent.adapterможет быть null.

mport android.graphics.Canvasimport android.graphics.Rectimport android.graphics.drawable.Drawableimport android.view.Viewimport androidx.core.view.childrenimport androidx.recyclerview.widget.RecyclerViewclass DividerItemDecorationLastExcluded(private val dividerDrawable: Drawable) :    RecyclerView.ItemDecoration() {    private val dividerWidth = dividerDrawable.intrinsicWidth    private val dividerHeight = dividerDrawable.intrinsicHeight    override fun getItemOffsets(rect: Rect, v: View, parent: RecyclerView, s: RecyclerView.State) {        parent.adapter?.let { adapter ->            val childAdapterPosition = parent.getChildAdapterPosition(v)                .let { if (it == RecyclerView.NO_POSITION) return else it }            rect.right = // Add space/"padding" on right side                if (childAdapterPosition == adapter.itemCount - 1) 0    // No "padding"                else dividerWidth                                       // Drawable width "padding"        }    }    override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {        parent.adapter?.let { adapter ->            parent.children // Displayed children on screen                .forEach { view ->                    val childAdapterPosition = parent.getChildAdapterPosition(view)                        .let { if (it == RecyclerView.NO_POSITION) return else it }                    if (childAdapterPosition != adapter.itemCount - 1) {                        val left = view.right                        val top = parent.paddingTop                        val right = left + dividerWidth                        val bottom = top + dividerHeight - parent.paddingBottom                        dividerDrawable.bounds = Rect(left, top, right, bottom)                        dividerDrawable.draw(canvas)                    }                }        }    }}

? Обратите внимание, что в большинстве случаев вам, вероятно, нужен только интервал между вашими элементами (за исключением последнего). Нет необходимости переопределять метод onDraw, достаточно установить правильный размер Rect в методе getItemsOffsets.

import android.graphics.Rectimport android.view.Viewimport androidx.recyclerview.widget.RecyclerViewclass SimpleDividerItemDecorationLastExcluded(private val spacing: Int) :    RecyclerView.ItemDecoration() {    override fun getItemOffsets(        rect: Rect,        view: View,        parent: RecyclerView,        s: RecyclerView.State    ) {        parent.adapter?.let { adapter ->            rect.right = when (parent.getChildAdapterPosition(view)) {                RecyclerView.NO_POSITION,                adapter.itemCount - 1 -> 0                else -> spacing            }        }    }}
Пользовательский DividerItemDecoration, с добавлением интервала между элементами, за исключением последнегоПользовательский DividerItemDecoration, с добавлением интервала между элементами, за исключением последнего

2.3. Оформление на основе типа View

В этом примере у нас есть 2 типа элементов, показанных черным и серым. Мы оформляем элементы синими и красными Drawable в зависимости от типа View (объявленных в Adapter-е).

Пользовательский ItemDecoration в зависимости от типа ViewПользовательский ItemDecoration в зависимости от типа View

Главная строчка кода здесь здесь - adapter.getItemViewType(childAdapterPosition). Нам нужно переопределить метод getItemViewType в нашем адаптере.

Он возвращает тип View элемента по позиции для повторного использования View. [] Подумайте над тем, чтобы использовать id-ресурсы для уникальной идентификации типов View.

См. официальную документацию getItemViewType.

Как только вы сможете определить тип вашего элемента, вы можете установить правильный интервал и украсить его по своему желанию ?.

import android.content.Contextimport android.graphics.Canvasimport android.graphics.Rectimport android.graphics.drawable.Drawableimport android.view.Viewimport androidx.core.content.ContextCompatimport androidx.core.view.childrenimport androidx.recyclerview.widget.RecyclerViewclass CustomItemDecoration(context: Context) : RecyclerView.ItemDecoration() {    private val decorationRed = ContextCompat.getDrawable(context, R.drawable.item_decoration_red)!!    private val decorationBlue = ContextCompat.getDrawable(context, R.drawable.item_decoration_blue)!!    override fun getItemOffsets(rect: Rect, view: View, parent: RecyclerView, s: RecyclerView.State) {        parent.adapter?.let { adapter ->            val childAdapterPosition = parent.getChildAdapterPosition(view)                .let { if (it == RecyclerView.NO_POSITION) return else it }            rect.right = when (adapter.getItemViewType(childAdapterPosition)) {                CustomAdapter.EVEN_ITEM_ID -> decorationRed.intrinsicWidth                CustomAdapter.ODD_ITEM_ID -> decorationBlue.intrinsicWidth                else -> 0            }        }    }    override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {        parent.adapter?.let { adapter ->            parent.children                .forEach { view ->                    val childAdapterPosition = parent.getChildAdapterPosition(view)                        .let { if (it == RecyclerView.NO_POSITION) return else it }                    when (adapter.getItemViewType(childAdapterPosition)) {                        CustomAdapter.EVEN_ITEM_ID -> decorationRed.drawSeparator(view, parent, canvas)                        CustomAdapter.ODD_ITEM_ID -> decorationBlue.drawSeparator(view, parent, canvas)                        else -> Unit                    }                }        }    }    private fun Drawable.drawSeparator(view: View, parent: RecyclerView, canvas: Canvas) =        apply {            val left = view.right            val top = parent.paddingTop            val right = left + intrinsicWidth            val bottom = top + intrinsicHeight - parent.paddingBottom            bounds = Rect(left, top, right, bottom)            draw(canvas)        }}

Вы можете найти полный код в моём GitHub репозитории.

Подробнее..

Категории

Последние комментарии

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru