![](https://habrastorage.org/webt/wk/q7/cq/wkq7cqwqisgnz3lof9qfobt5bte.png)
Google продолжает улучшать нашу жизнь, выпуская новые удобные библиотеки и API. Среди которых оказался и новый MotionLayout. Учитывая обилие анимаций в наших приложениях, мой коллега Cedric Holtz сразу же реализовал важнейшую анимацию нашего приложения — голосование в знакомствах — с использованием нового API, сэкономив при этом огромное количество кода. Делюсь переводом его статьи.
Недавно закончилась конференция Google I/O 2019, на которой анонсировали обновления и самые свежие улучшения нашего любимого SDK. Лично мне особенно интересна была презентация Николаса Роарда и Джона Хофорда о будущей функциональности ConstraintLayout. А точнее, о его расширении в виде MotionLayout.
После выпуска бета-версии мне захотелось реализовать анимацию знакомств на основе этой библиотеки.
Сначала определимся с терминами:
«MotionLayout — это ConstraintLayout, который позволяет анимировать лэйауты между разными состояниями». — Документация
Если вы ещё не читали серию статей Николаса Роарда, в которой объясняются ключевые идеи MotionLayout, то очень рекомендую прочитать.
Итак, с введением закончили, теперь давайте посмотрим, что мы хотим получить:
![](https://habrastorage.org/webt/wm/kb/qm/wmkbqmy_ulwafy_jvkvpadfafyu.gif)
Стек карт
Показываем сдвигаемую карту
Начнём с того, что в директорию лэйаутов добавим 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.
Посмотрим, что получилось:
![](https://habrastorage.org/webt/zs/zq/ox/zszqoxe4dsdpphvu3fro0_3tjtk.gif)
Карта автоматически выходит за пределы экрана
Теперь поработаем над анимацией, которая будет запускаться, когда карта выходит за пределы экрана.
Добавим ещё два набора 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" />
Теперь у нас есть свайпабельная карта, которую можно свайпнуть с экрана!
![](https://habrastorage.org/webt/vt/ip/i0/vtipi0xzpmomh5mlkqpoytwedze.gif)
Анимация нижней карты
Теперь сделаем нижнюю карту, чтобы создать иллюзию бесконечности колоды.
Добавим в лэйаут ещё одну карту, аналогичную первой:
<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, то тем самым переопределяем все ограничения из родительского набора. Чтобы этого избежать, можно задать в тегах конкретные атрибуты, чтобы замещались лишь они.
![](https://habrastorage.org/webt/vz/ff/zb/vzffzb5u7rlwpayywz7zfm6gnyk.png)
В нашем случае это означает, что в наборе pass мы не определяем тег Layout, а копируем из родителя. Однако мы переопределяем Transform, поэтому поэтому заменяем все атрибуты, заданные в теге Transform, нашими собственными, в данном случае — изменением масштаба.
Вот так легко можно с помощью MotionLayout добавить новый элемент и бесшовно интегрировать его с анимациями нашей сцены.
![](https://habrastorage.org/webt/_-/73/jp/_-73jp-vstz02uondgcnyzwcxqe.gif)
Делаем анимацию бесконечной
После завершения анимации верхнюю карту уже нельзя смахнуть, потому что теперь она стала нижней картой. Чтобы получилась бесконечная анимация, нужно менять карты местами.
Сначала я хотел сделать это с помощью нового перехода:
<Transition
app:autoTransition="jumpToEnd"
app:constraintSetEnd="@+id/rest"
app:constraintSetStart="@+id/offScreenLike"
app:duration="0" />
![](https://habrastorage.org/webt/jb/6h/lz/jb6hlzynphpyptximrzq1wgfqfw.gif)
Анимация целиком проигрывается так, как нужно. Теперь у нас есть стек карт, которые можно бесконечно свайпить!
Посвайпив немного, я кое-что заметил. Анимация перехода к концу колоды останавливается, если коснуться карты. Даже при том, что длительность анимации нулевая, всё равно происходит остановка, а это плохо.
![](https://habrastorage.org/webt/yv/nq/mo/yvnqmowevaznpjimh5kbtiplwco.gif)
Мне удалось победить только одним способом — программно изменив активный переход в 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, при свайпе мы переключаемся на нужный.
![](https://habrastorage.org/webt/li/9b/yo/li9byopqw8itzxzsgj2wgwbufxm.gif)
Выглядит так же, но анимация не останавливается! Идём дальше!
Привязка (биндинг) данных
Создадим тестовые данные для отображения на картах. Пока что ограничимся изменением фонового цвета у каждой карты.
Мы создаем ViewModel со свайп-методом, который всего лишь подставляет новые данные. Биндим её в Activity таким образом:
val viewModel = ViewModelProviders
.of(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 о завершении анимации свайпа, и она обновит данные, которые отображаются в текущий момент.
![](https://habrastorage.org/webt/vl/j6/ar/vlj6arofcxa3lc7ny2z-ww1yxxg.gif)
Всплывающие иконки
Добавим две вьюхи, которые при свайпе появляются с одной из сторон экрана (ниже показана только одна, вторая делается зеркально).
<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>
Нет нужды задавать ограничения в анимациях, которые выходят за пределы экрана, поскольку они наследуются от родителей. А в нашем случае это состояние свайпа.
Это всё, что нам нужно сделать. Теперь можно очень легко добавлять компоненты в цепочки анимаций.
![](https://habrastorage.org/webt/wg/4g/1g/wg4g1grirk3zwqrfzwyahfp8i3i.gif)
Запускаем анимацию программно
Можем сделать на картах две кнопки, чтобы пользователь мог не только свайпить, но управлять с помощью кнопок.
Каждая кнопка запускает ту же анимацию, что и свайп.
Как обычно, подписываемся на клики кнопок и запустим анимацию прямо на объекте MotionLayout:
likeButton.setOnClickListener {
motionLayout.transitionToState(R.id.like)
}
passButton.setOnClickListener {
motionLayout.transitionToState(R.id.pass)
}
Нам нужно добавить кнопки как на верхнюю, так и на нижнюю карты, чтобы анимация проигрывалась непрерывно. Однако для нижней карты подписка на клики не нужна, потому что она либо не видна, либо верхняя карта анимируется, и мы не хотим это прерывать.
![](https://habrastorage.org/webt/ic/zj/_v/iczj_vz8nrpdzlxtmjsaqc3bmiu.gif)
Ещё один замечательный пример того, как MotionLayout обрабатывает для нас изменения состояний. Давайте слегка замедлим анимацию:
![](https://habrastorage.org/webt/d6/bk/ne/d6bkne2bvugopppwypug25l30m8.gif)
Посмотрите на переход, который выполняет 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>
![](https://habrastorage.org/webt/ij/z4/4m/ijz44micij7kdu81k4-tc-zggjc.gif)
Теперь карта движется по небанальной изогнутой траектории. Волшебно!
Заключение
Когда сравниваешь объём кода, получившийся у меня при создании этих анимаций, с нашей текущей реализацией похожей анимации в продакшне, результат ошеломляет.
MotionLayout незаметно обрабатывает отмену переходов (например, при касании), создание цепочек анимаций, изменения свойства при переходах и многое другое. Этот инструмент в корне всё меняет, значительно упрощая UI-логику.
Есть еще некоторые вещи, над которыми стоит поработать (в основном, отключение анимаций и двунаправленный скроллинг в RecyclerView), но уверен, что это решаемо.
Помните, что библиотека ещё находится в статусе беты, но она уже открывает для нас много захватывающих возможностей. С нетерпением ждем релиза MotionLayout, который, я уверен, еще не раз пригодится нам в будущем. Полностью работающее приложение из этой статьи вы можете посмотреть в репозитории.
P.S.: и раз уж мне как переводчику предоставили слово — в нашей Android-команде есть место разработчика. Спасибо за внимание.