В этой статье я расскажу и покажу, как создавать красивые анимированные списки на основе 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://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://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://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://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://schemas.android.com/apk/res/android"
    xmlns:app="http://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.model

import com.mjmanaog.foodbuddy.R

data 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 содержится описание анимации, которую мы настроили. Никто не мешает вам создавать и редактировать анимации в файлах сцены вручную.

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

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