В этой статье я расскажу и покажу, как создавать красивые анимированные списки на основе 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В папке 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.
Теперь мы можем работать с 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
-
В дереве элементов выберите ivFood (ImageView с основной картинкой);
-
В редакторе MotionLayout выберите end;
-
У выделенного элемента ivFood выделите правую (End) опорную точку и перетащите её за правую (End) границу родительского элемента;
-
Картинка должна встать по центру родительского элемента;
-
Поменяйте значение атрибутов layout_height и layout_width на 300dp.
От переводчика: начальное состояние ImageView (его положение, ширина и высота) осталось без изменений, а его конечное состояние изменилось: он встанет по центру и увеличится в размере в два раза (с 150dp до 300dp).
Шаг 6: посмотрим, что получилось
Чтобы воспроизвести анимацию, которую мы только что настроили:
-
В редакторе MotionLayout выделите толстую стрелку, которая соединяет прямоугольники с надписями start и end;
-
В редакторе ниже станет доступным блок Transition;
-
Нажмите кнопку Play, чтобы воспроизвести анимацию.
Шаг 7: добавим анимацию на CardView
Порядок действий схож:
-
В дереве компонентов выделите cardView (constraintView с заголовком, описанием, калорийностью и оценкой);
-
В редакторе MotionLayout выберите end;
-
Выделите cardView в появившемся разделе ConstraintSet;
-
В разделе атрибутов элемента перейдите к группе Transforms;
-
Поменяйте значение атрибута alpha на 0.
От переводчика: у карточки с описанием блюда конечное состояние (end) от начального (start) отличается только значением параметра alpha. В конечном состоянии она будет скрыта (и скрываться она будет плавно).
Шаг 8: добавим обработчик нажатий
Чтобы анимация включалась, надо настроить обработчик нажатий:
-
В разделе атрибутов под OnClick добавьте новое поле (кнопка +);
-
В параметра targetId выберите значение ivFood;
-
Добавьте ещё одно поле;
-
Для параметра 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 содержится описание анимации, которую мы настроили. Никто не мешает вам создавать и редактировать анимации в файлах сцены вручную.
Надеюсь, материал из этой статьи кому-то окажется полезным. Будет круто, если вы узнаете из неё что-то новое.
Спасибо за внимание.