MotionLayout: анимации лучше, кода — меньше
Google продолжает улучшать нашу жизнь, выпуская новые удобные библиотеки и API, среди которых оказался и новый MotionLayout. Учитывая обилие анимаций в наших приложениях (см. источник), мой коллега Cedric Holtz сразу же реализовал важнейшую анимацию нашего приложения — голосование в знакомствах — с использованием нового API, сэкономив при этом огромное количество кода. Делюсь переводом его статьи.
Примечание: оригинал статьи был написан в июле 2019 года, некоторые моменты в статье сейчас могут быть неактуальными.
Недавно закончилась конференция Google I/O 2019, на которой анонсировали обновления и самые свежие улучшения нашего любимого SDK. Лично мне особенно интересна была презентация Николаса Роарда и Джона Хофорда о будущей функциональности ConstraintLayout. А точнее, о его расширении в виде MotionLayout.
После выпуска бета-версии мне захотелось реализовать анимацию знакомств на основе этой библиотеки.
Сначала определимся с терминами:
«MotionLayout — это ConstraintLayout, который позволяет анимировать лэйауты между разными состояниями». — Документация
Если вы ещё не читали серию статей Николаса Роарда, в которой объясняются ключевые идеи MotionLayout, то очень рекомендую прочитать.
Итак, с введением закончили, теперь давайте посмотрим, что мы хотим получить:
Стек карт
Показываем сдвигаемую карту
Начнём с того, что в директорию лэйаутов добавим MotionLayout, который пока что содержит только одну верхнюю карту:
<androidx.constraintlayout.motion.widget.MotionLayout android:id="@+id/motionLayout" 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" app:layoutDescription="@xml/scene_swipe" app:motionDebug="SHOW_ALL"> <FrameLayout android:id="@+id/topCard" android:layout_width="0dp" android:layout_height="0dp" /> </androidx.constraintlayout.motion.widget.MotionLayout>
Обратите внимание на эту строку: app:motionDebug=«SHOW_ALL». Она позволяет нам выводить на экран отладочную информацию, траекторию движения объектов, состояния с началом и концом анимации, а также текущий прогресс. Строчка очень помогает при отладке, но не забудьте удалить её, прежде чем отправлять в прод: никакой напоминалки для этого нет.
Как видите, мы не задали никаких ограничений для вьюх здесь. Они будут взяты из сцены (MotionScene), которую мы сейчас определим.
Начнём с того, что определим начальное состояние: одна карта лежит в центре экрана, с отступами вокруг.
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <ConstraintSet android:id="@+id/rest"> <Constraint android:id="@id/topCard" android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginBottom="50dp" android:layout_marginEnd="50dp" android:layout_marginStart="50dp" android:layout_marginTop="50dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> </ConstraintSet> </MotionScene>
Добавим наборы ограничений (ConstraintSet) pass и like. Они будут отражать состояние верхней карты, когда она полностью сдвинута влево или вправо. Мы хотим, чтобы перед исчезновением с экрана карта остановилась, чтобы показать красивую анимацию, подтверждающую наше решение.
<ConstraintSet android:id="@+id/pass" app:deriveConstraintsFrom="@+id/rest"> <Constraint android:id="@id/topCard" android:layout_width="0dp" android:layout_height="match_parent" android:layout_marginBottom="80dp" android:layout_marginEnd="200dp" android:layout_marginStart="50dp" android:layout_marginTop="20dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintWidth_percent="0.7" /> </ConstraintSet> <ConstraintSet android:id="@+id/like" app:deriveConstraintsFrom="@id/rest"> <Constraint android:id="@id/topCard" android:layout_width="0dp" android:layout_height="match_parent" android:layout_marginBottom="80dp" android:layout_marginEnd="50dp" android:layout_marginStart="200dp" android:layout_marginTop="20dp" app:layout_constraintStart_toStartOf="parent" app:layout_constraintWidth_percent="0.7" /> </ConstraintSet>
Добавим в предыдущую сцену оба набора ограничений. Они почти одинаковые, только зеркально отражены по обеим сторонам экрана.
Теперь у нас три набора ограничений — start, like и pass. Давайте определим переходы (Transition) между этими состояниями.
Для этого добавим в сцену один переход для свайпа влево, другой для свайпа вправо.
<Transition app:constraintSetEnd="@+id/pass" app:constraintSetStart="@+id/rest" app:duration="300"> <OnSwipe app:dragDirection="dragLeft" app:onTouchUp="autoComplete" app:touchAnchorId="@id/topCard" app:touchAnchorSide="left" app:touchRegionId="@id/topCard" /> </Transition> <Transition app:constraintSetEnd="@+id/like" app:constraintSetStart="@+id/rest" app:duration="300"> <OnSwipe app:dragDirection="dragRight" app:onTouchUp="autoComplete" app:touchAnchorId="@+id/topCard" app:touchAnchorSide="right" app:touchRegionId="@id/topCard" /> </Transition>
Итак, для верхней карты мы задали анимацию свайпа влево и такую же — зеркально для свайпа вправо.
Эти свойства помогут улучшить взаимодействие с нашей сценой:
- touchRegionId: поскольку мы добавили вокруг карты отступы, нужно сделать так, чтобы касание распознавалось лишь в зоне самой карты, а не всего MotionLayout. Это можно сделать с помощью touchRegionId.
- onTouchUp: что будет с анимацией после того, как мы отпустим карту? Она должна либо двигаться дальше, либо вернуться в начальное состояние, поэтому применим autoComplete.
Посмотрим, что получилось:
Карта автоматически выходит за пределы экрана
Теперь поработаем над анимацией, которая будет запускаться, когда карта выходит за пределы экрана.
Добавим ещё два набора ConstraintSet для каждого конечного состояния наших анимаций: выход карты за пределы экрана слева и справа.
В следующих примерах я покажу, как сделать состояние like, а состояние pass будет повторять его зеркально. Рабочий пример можно полностью увидеть в репозитории.
<ConstraintSet android:id="@+id/offScreenLike"> <Constraint android:id="@id/topCard" android:layout_width="0dp" android:layout_height="match_parent" android:layout_marginBottom="80dp" android:layout_marginEnd="50dp" android:layout_marginTop="20dp" app:layout_constraintStart_toEndOf="parent" app:layout_constraintWidth_percent="0.7" /> </ConstraintSet>
Теперь, как и в предыдущем примере, нужно определить переход от состояния свайпа к конечному состоянию. Переход должен автоматически срабатывать сразу после завершения анимации свайпа. Сделать это можно с помощью autoTransition:
<Transition app:autoTransition="animateToEnd" app:constraintSetEnd="@+id/offScreenLike" app:constraintSetStart="@+id/like" app:duration="150" />
Теперь у нас есть свайпабельная карта, которую можно свайпнуть с экрана!
Анимация нижней карты
Теперь сделаем нижнюю карту, чтобы создать иллюзию бесконечности колоды.
Добавим в лэйаут ещё одну карту, аналогичную первой:
<FrameLayout android:id="@+id/bottomCard" android:layout_width="0dp" android:layout_height="0dp" android:background="@color/colorAccent" />
Изменим XML, чтобы задать ограничения, которые применяются к этой карте на каждом этапе анимации:
<ConstraintSet android:id="@id/rest"> <!--...--> <Constraint android:id="@id/bottomCard"> <Layout android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginBottom="50dp" android:layout_marginEnd="50dp" android:layout_marginStart="50dp" android:layout_marginTop="50dp" /> <Transform android:scaleX="0.90" android:scaleY="0.90" /> </Constraint> </ConstraintSet> <ConstraintSet android:id="@+id/offScreenLike" app:deriveConstraintsFrom="@id/like"> <!--...--> <Constraint android:id="@id/bottomCard"> <Transform android:scaleX="1" android:scaleY="1" /> </Constraint> </ConstraintSet>
Для этого мы можем воспользоваться удобным свойством ConstraintSet.
По умолчанию, каждый новый набор берёт атрибуты из родительского MotionLayout. Но с помощью флага deriveConstraintsFrom можно задать для нашего набора другого родителя. Стоит иметь в виду, что если мы задаем ограничения с помощью тега constraint, то тем самым переопределяем все ограничения из родительского набора. Чтобы этого избежать, можно задать в тегах конкретные атрибуты, чтобы замещались лишь они.
В нашем случае это означает, что в наборе pass мы не определяем тег Layout, а копируем из родителя. Однако мы переопределяем Transform, поэтому поэтому заменяем все атрибуты, заданные в теге Transform, нашими собственными, в данном случае — изменением масштаба.
Вот так легко можно с помощью MotionLayout добавить новый элемент и бесшовно интегрировать его с анимациями нашей сцены.
Делаем анимацию бесконечной
После завершения анимации верхнюю карту уже нельзя смахнуть, потому что теперь она стала нижней картой. Чтобы получилась бесконечная анимация, нужно менять карты местами.
Сначала я хотел сделать это с помощью нового перехода:
<Transition app:autoTransition="jumpToEnd" app:constraintSetEnd="@+id/rest" app:constraintSetStart="@+id/offScreenLike" app:duration="0" />
Анимация целиком проигрывается так, как нужно. Теперь у нас есть стек карт, которые можно бесконечно свайпить!
Посвайпив немного, я кое-что заметил. Анимация перехода к концу колоды останавливается, если коснуться карты. Даже при том, что длительность анимации нулевая, всё равно происходит остановка, а это плохо.
Мне удалось победить только одним способом — программно изменив активный переход в MotionLayout.
Для этого мы зададим коллбэк по завершению анимации. Как только завершаются offScreenLike и offScreenPass, мы просто сбрасываем переход обратно на состояние rest и обнуляем прогресс.
motionLayout.setTransitionListener(object : TransitionAdapter() { override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) { when (currentId) { R.id.offScreenPass, R.id.offScreenLike -> { motionLayout.progress = 0f motionLayout.setTransition(R.id.rest, R.id.like) } } } })
Не имеет значения, какой переход мы задали, pass или like, при свайпе мы переключаемся на нужный.
Выглядит так же, но анимация не останавливается! Идём дальше!
Привязка (биндинг) данных
Создадим тестовые данные для отображения на картах. Пока что ограничимся изменением фонового цвета у каждой карты.
Мы создаем ViewModel со свайп-методом, который всего лишь подставляет новые данные. Биндим её в Activity таким образом:
val viewModel = ViewModelProvider(this).get(SwipeRightViewModel::class.java) viewModel.modelStream.observe(this, Observer { bindCard(it) }) motionLayout.setTransitionListener(object : TransitionAdapter() { override fun onTransitionCompleted(motionLayout: MotionLayout, currentId: Int) { when (currentId) { R.id.offScreenPass, R.id.offScreenLike -> { motionLayout.progress = 0f motionLayout.setTransition(R.id.rest, R.id.like) viewModel.swipe() } } } })
Осталось сообщить ViewModel о завершении анимации свайпа, и она обновит данные, которые отображаются в текущий момент.
Всплывающие иконки
Добавим две вьюхи, которые при свайпе появляются с одной из сторон экрана (ниже показана только одна, вторая делается зеркально).
<ImageView android:id="@+id/likeIndicator" android:layout_width="0dp" android:layout_height="0dp" />
Теперь для карт нужно задать состояния анимации с этим вьюхами.
<ConstraintSet android:id="@id/rest"> <!-- ... --> <Constraint android:id="@+id/like"> <Layout android:layout_width="40dp" android:layout_height="40dp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Transform android:scaleX="0.5" android:scaleY="0.5" /> <PropertySet android:alpha="0" /> </Constraint> </ConstraintSet> <ConstraintSet android:id="@+id/like" app:deriveConstraintsFrom="@id/rest"> <!-- ... --> <Constraint android:id="@+id/like"> <Layout android:layout_width="100dp" android:layout_height="100dp" app:layout_constraintBottom_toBottomOf="@id/topCard" app:layout_constraintEnd_toEndOf="@id/topCard" app:layout_constraintStart_toStartOf="@id/topCard" app:layout_constraintTop_toTopOf="@id/topCard" /> <Transform android:scaleX="1" android:scaleY="1" /> <PropertySet android:alpha="1" /> </Constraint> </ConstraintSet>
Нет нужды задавать ограничения в анимациях, которые выходят за пределы экрана, поскольку они наследуются от родителей. А в нашем случае это состояние свайпа.
Это всё, что нам нужно сделать. Теперь можно очень легко добавлять компоненты в цепочки анимаций.
Запускаем анимацию программно
Можем сделать на картах две кнопки, чтобы пользователь мог не только свайпить, но и управлять с помощью кнопок.
Каждая кнопка запускает ту же анимацию, что и свайп.
Как обычно, подписываемся на клики кнопок и запускаем анимацию прямо на объекте MotionLayout:
likeButton.setOnClickListener { motionLayout.transitionToState(R.id.like) } passButton.setOnClickListener { motionLayout.transitionToState(R.id.pass) }
Нам нужно добавить кнопки как на верхнюю, так и на нижнюю карты, чтобы анимация проигрывалась непрерывно. Однако для нижней карты подписка на клики не нужна, потому что она либо не видна, либо верхняя карта анимируется, и мы не хотим это прерывать.
Ещё один замечательный пример того, как MotionLayout обрабатывает для нас изменения состояний. Давайте слегка замедлим анимацию:
Посмотрите на переход, который выполняет MotionLayout, когда pass сменяет like. Магия!
Свайпим карту по кривой
Допустим, нам нравится, если карта будет двигаться не по прямой, а по кривой (честно говоря, мне просто хотелось попробовать так сделать).
Тогда нужно для движения в обе стороны определить KeyPosition, чтобы траектория движения изогнулась дугой.
Добавим это в сцену движения:
<Transition app:constraintSetEnd="@+id/like" app:constraintSetStart="@+id/rest" app:duration="300"> <!-- ... --> <KeyFrameSet> <KeyPosition app:drawPath="path" app:framePosition="50" app:keyPositionType="pathRelative" app:motionTarget="@id/topCard" app:percentX="0.5" app:percentY="-0.1" /> </KeyFrameSet> </Transition>
Теперь карта движется по небанальной изогнутой траектории. Волшебно!
Заключение
Когда сравниваешь объём кода, получившийся у меня при создании этих анимаций, с нашей текущей реализацией похожей анимации в продакшне, результат ошеломляет.
MotionLayout незаметно обрабатывает отмену переходов (например, при касании), создание цепочек анимаций, изменения свойства при переходах и многое другое. Этот инструмент в корне всё меняет, значительно упрощая UI-логику.
Есть еще некоторые вещи, над которыми стоит поработать (в основном, отключение анимаций и двунаправленный скроллинг в RecyclerView), но уверен, что это решаемо.
Помните, что библиотека ещё находится в статусе беты (на июль 2019 года), но она уже открывает для нас много захватывающих возможностей. С нетерпением ждем релиза MotionLayout, который, я уверен, еще не раз пригодится нам в будущем. Полностью работающее приложение из этой статьи вы можете посмотреть в репозитории.
Спасибо за внимание.