[Перевод] MotionLayout: анимации лучше, кода — меньше

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

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

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

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

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

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

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

wmkbqmy_ulwafy_jvkvpadfafyu.gif

Стек карт


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


Начнём с того, что в директорию лэйаутов добавим MotionLayout, который пока что содержит только одну верхнюю карту:



    


Обратите внимание на эту строку: app: motionDebug=«SHOW_ALL». Она позволяет нам выводить на экран отладочную информацию, траекторию движения объектов, состояния с началом и концом анимации, а также текущий прогресс. Строчка очень помогает при отладке, но не забудьте удалить её, прежде чем отправлять в прод: никакой напоминалки для этого нет.

Как видите, мы не задали никаких ограничений для вьюх здесь. Они будут взяты из сцены (MotionScene), которую мы сейчас определим.

Начнём с того, что определим начальное состояние: одна карта лежит в центре экрана, с отступами вокруг.



    

        
    


Добавим наборы ограничений (ConstraintSet) pass и like. Они будут отражать состояние верхней карты, когда она полностью сдвинута влево или вправо. Мы хотим, чтобы перед исчезновением с экрана карта остановилась, чтобы показать красивую анимацию, подтверждающую наше решение.



    




    


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

Теперь у нас три набора ограничений — start, like и pass. Давайте определим переходы (Transition) между этими состояниями.

Для этого добавим в сцену один переход для свайпа влево, другой для свайпа вправо.



    




    


Итак, для верхней карты мы задали анимацию свайпа влево и такую же — зеркально для свайпа вправо.

Эти свойства помогут улучшить взаимодействие с нашей сценой:

  • touchRegionId: поскольку мы добавили вокруг карты отступы, нужно сделать так, чтобы касание распознавалось лишь в зоне самой карты, а не всего MotionLayout. Это можно сделать с помощью touchRegionId.
  • onTouchUp: что будет с анимацией после того, как мы отпустим карту? Она должна либо двигаться дальше, либо вернуться в начальное состояние, поэтому применим autoComplete.


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

zszqoxe4dsdpphvu3fro0_3tjtk.gif

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


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

Добавим ещё два набора ConstraintSet для каждого конечного состояния наших анимаций: выход карты за пределы экрана слева и справа.

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



    
    


Теперь, как и в предыдущем примере, нужно определить переход от состояния свайпа к конечному состоянию. Переход должен автоматически срабатывать сразу после завершения анимации свайпа. Сделать это можно с помощью autoTransition:



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

vtipi0xzpmomh5mlkqpoytwedze.gif

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

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

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


Изменим XML, чтобы задать ограничения, которые применяются к этой карте на каждом этапе анимации:



    

    

        

        

    





    

    

        

    


Для этого мы можем воспользоваться удобным свойством ConstraintSet. 

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

vzffzb5u7rlwpayywz7zfm6gnyk.png

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

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

_-73jp-vstz02uondgcnyzwcxqe.gif

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

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

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


jb6hlzynphpyptximrzq1wgfqfw.gif

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

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

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, при свайпе мы переключаемся на нужный.

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

vlj6arofcxa3lc7ny2z-ww1yxxg.gif

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


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



Теперь для карт нужно задать состояния анимации с этим вьюхами.



    

    

        

        

        

    
    




    

    

        

        

        

    



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

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

wg4g1grirk3zwqrfzwyahfp8i3i.gif

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

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

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

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

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


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

ijz44micij7kdu81k4-tc-zggjc.gif

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

d6bkne2bvugopppwypug25l30m8.gif

Посмотрите на переход, который выполняет MotionLayout, когда pass сменяет like. Магия!


Допустим, нам нравится, если карта будет двигаться не по прямой, а по кривой (честно говоря, мне просто хотелось попробовать так сделать).

Тогда нужно для движения в обе стороны определить KeyPosition, чтобы траектория движения изогнулась дугой.

Добавим это в сцену движения:



    

    

        
        
    


iczj_vz8nrpdzlxtmjsaqc3bmiu.gif

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


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

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

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

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

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

© Habrahabr.ru