MotionLayout + RecyclerView = красивые анимированные списки
В этой статье я расскажу и покажу, как создавать красивые анимированные списки на основе 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: создадим лэйаут
Наш будущий элемент списка будет выглядеть так:
В папке 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.
Теперь мы можем работать с 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
В дереве элементов выберите 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://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 содержится описание анимации, которую мы настроили. Никто не мешает вам создавать и редактировать анимации в файлах сцены вручную.
Надеюсь, материал из этой статьи кому-то окажется полезным. Будет круто, если вы узнаете из неё что-то новое.
Спасибо за внимание.