MotionLayout: анимации лучше, кода — меньше

в 17:25, , рубрики: Без рубрики

MotionLayout: анимации лучше, кода — меньше - 1
Google продолжает улучшать нашу жизнь, выпуская новые удобные библиотеки и API. Среди которых оказался и новый MotionLayout. Учитывая обилие анимаций в наших приложениях, мой коллега Cedric Holtz сразу же реализовал важнейшую анимацию нашего приложения — голосование в знакомствах — с использованием нового API, сэкономив при этом огромное количество кода. Делюсь переводом его статьи. 

Недавно закончилась конференция Google I/O 2019, на которой анонсировали обновления и самые свежие улучшения нашего любимого SDK. Лично мне особенно интересна была презентация Николаса Роарда и Джона Хофорда о будущей функциональности ConstraintLayout. А точнее, о его расширении в виде MotionLayout. 

После выпуска бета-версии мне захотелось реализовать анимацию знакомств на основе этой библиотеки.

Сначала определимся с терминами:

«MotionLayout — это ConstraintLayout, который позволяет анимировать лэйауты между разными состояниями».  —  Документация

Если вы ещё не читали серию статей Николаса Роарда, в которой объясняются ключевые идеи MotionLayout, то очень рекомендую прочитать.

Итак, с введением закончили, теперь давайте посмотрим, что мы хотим получить:

MotionLayout: анимации лучше, кода — меньше - 2

Стек карт

Показываем сдвигаемую карту

Начнём с того, что в директорию лэйаутов добавим 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.

Посмотрим, что получилось:

MotionLayout: анимации лучше, кода — меньше - 3

Карта автоматически выходит за пределы экрана

Теперь поработаем над анимацией, которая будет запускаться, когда карта выходит за пределы экрана.

Добавим ещё два набора 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" />

Теперь у нас есть свайпабельная карта, которую можно свайпнуть с экрана!

MotionLayout: анимации лучше, кода — меньше - 4

Анимация нижней карты

Теперь сделаем нижнюю карту, чтобы создать иллюзию бесконечности колоды.

Добавим в лэйаут ещё одну карту, аналогичную первой:

<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, то тем самым переопределяем все ограничения из родительского набора. Чтобы этого избежать, можно задать в тегах конкретные атрибуты, чтобы замещались лишь они.

MotionLayout: анимации лучше, кода — меньше - 5

В нашем случае это означает, что в наборе pass мы не определяем тег Layout, а копируем из родителя. Однако мы переопределяем Transform, поэтому поэтому заменяем все атрибуты, заданные в теге Transform, нашими собственными, в данном случае — изменением масштаба.

Вот так легко можно с помощью MotionLayout добавить новый элемент и бесшовно интегрировать его с анимациями нашей сцены.

MotionLayout: анимации лучше, кода — меньше - 6

Делаем анимацию бесконечной

После завершения анимации верхнюю карту уже нельзя смахнуть, потому что теперь она стала нижней картой. Чтобы получилась бесконечная анимация, нужно менять карты местами. 

Сначала я хотел сделать это с помощью нового перехода:

<Transition
    app:autoTransition="jumpToEnd"
    app:constraintSetEnd="@+id/rest"
    app:constraintSetStart="@+id/offScreenLike"
    app:duration="0" />

MotionLayout: анимации лучше, кода — меньше - 7

Анимация целиком проигрывается так, как нужно. Теперь у нас есть стек карт, которые можно бесконечно свайпить!

Посвайпив немного, я кое-что заметил. Анимация перехода к концу колоды останавливается, если коснуться карты. Даже при том, что длительность анимации нулевая, всё равно происходит остановка, а это плохо. 

MotionLayout: анимации лучше, кода — меньше - 8

Мне удалось победить только одним способом — программно изменив активный переход в 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, при свайпе мы переключаемся на нужный.

MotionLayout: анимации лучше, кода — меньше - 9

Выглядит так же, но анимация не останавливается! Идём дальше!

Привязка (биндинг) данных

Создадим тестовые данные для отображения на картах. Пока что ограничимся изменением фонового цвета у каждой карты.

Мы создаем 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 о завершении анимации свайпа, и она обновит данные, которые отображаются в текущий момент.

MotionLayout: анимации лучше, кода — меньше - 10

Всплывающие иконки

Добавим две вьюхи, которые при свайпе появляются с одной из сторон экрана (ниже показана только одна, вторая делается зеркально).

<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: анимации лучше, кода — меньше - 11

Запускаем анимацию программно

Можем сделать на картах две кнопки, чтобы пользователь мог не только свайпить, но управлять с помощью кнопок.

Каждая кнопка запускает ту же анимацию, что и свайп.

Как обычно, подписываемся на клики кнопок и запустим анимацию прямо на объекте MotionLayout:

likeButton.setOnClickListener {
    motionLayout.transitionToState(R.id.like)
}
passButton.setOnClickListener {
    motionLayout.transitionToState(R.id.pass)
}

Нам нужно добавить кнопки как на верхнюю, так и на нижнюю карты, чтобы анимация проигрывалась непрерывно. Однако для нижней карты подписка на клики не нужна, потому что она либо не видна, либо верхняя карта анимируется, и мы не хотим это прерывать.

MotionLayout: анимации лучше, кода — меньше - 12

Ещё один замечательный пример того, как MotionLayout обрабатывает для нас изменения состояний. Давайте слегка замедлим анимацию:

MotionLayout: анимации лучше, кода — меньше - 13

Посмотрите на переход, который выполняет 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: анимации лучше, кода — меньше - 14

Теперь карта движется по небанальной изогнутой траектории. Волшебно!

Заключение

Когда сравниваешь объём кода, получившийся у меня при создании этих анимаций, с нашей текущей реализацией похожей анимации в продакшне, результат ошеломляет. 

MotionLayout незаметно обрабатывает отмену переходов (например, при касании), создание цепочек анимаций, изменения свойства при переходах и многое другое. Этот инструмент в корне всё меняет, значительно упрощая UI-логику. 

Есть еще некоторые вещи, над которыми стоит поработать (в основном, отключение анимаций и двунаправленный скроллинг в RecyclerView), но уверен, что это решаемо.

Помните, что библиотека ещё находится в статусе беты, но она уже открывает для нас много захватывающих возможностей. С нетерпением ждем релиза MotionLayout, который, я уверен, еще не раз пригодится нам в будущем. Полностью работающее приложение из этой статьи вы можете посмотреть в репозитории.

P.S.: и раз уж мне как переводчику предоставили слово — в нашей Android-команде есть место разработчика. Спасибо за внимание. 

Автор: journeymanmw

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js