Анимации в Android на базе Kotlin и RxJava



    Привет, Хабр! В прошлом году на MBLT DEV выступал Ivan Škorić из PSPDFKit c докладом о создании анимаций в Android на базе Kotlin и библиотеки RxJava.

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

    Анимация


    В Android есть 4 класса, которые применяются как бы по умолчанию:

    1. ValueAnimator — этот класс предоставляет простой механизм синхронизации для запуска анимаций, которые вычисляют анимированные значения и устанавливают их для View.
    2. ObjectAnimator — это подкласс ValueAnimator, который позволяет поддерживать анимацию для свойств объекта.
    3. AnimatorSet применяется для создания последовательности анимаций. Например, у вас есть последовательность из анимаций:

      1. View выезжает слева на экране.
      2. После завершения первой анимации мы хотим выполнить анимацию появления для другой View и т.д.
    4. ViewPropertyAnimator — автоматически запускает и оптимизирует анимации для выбранного свойства View. В основном мы будем использовать именно его. Поэтому мы применим этот API-интерфейс, а затем поместим его в RxJava в рамках реактивного программирования.


    ValueAnimator


    Разберём фреймворк ValueAnimator. Он применяется для изменения значения. Вы задаёте диапазон значений через ValueAnimator.ofFloat для примитивного типа float от 0 до 100. Указываете значение длительности Duration и запускаете анимацию.
    Рассмотрим на примере:

    val animator = ValueAnimator.ofFloat(0f, 100f)
    animator.duration = 1000
    animator.start()
    
    animator.addUpdateListener(object : ValueAnimator.AnimatorUpdateListener {
       override fun onAnimationUpdate(animation: ValueAnimator) {
           val animatedValue = animation.animatedValue as Float
           textView.translationX = animatedValue
       }
    })
    

    Здесь добавляем UpdateListener и при каждом обновлении будем двигать нашу View по горизонтали и менять её положение от 0 до 100, хотя это не очень хороший способ выполнения этой операции.

    ObjectAnimator


    Ещё один пример реализации анимации — ObjectAnimator:

    val objectAnimator = ObjectAnimator.ofFloat(textView, "translationX", 100f)
    objectAnimator.duration = 1000
    objectAnimator.start()

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

    AnimatorSet


    Теперь рассмотрим класс AnimatorSet:

    val bouncer = AnimatorSet()
    
    bouncer.play(bounceAnim).before(squashAnim1)
    bouncer.play(squashAnim1).before(squashAnim2)
    
    val fadeAnim = ObjectAnimator.ofFloat(newBall, "alpha", 1f, 0f)
    fadeAnim.duration = 250
    val animatorSet = AnimatorSet()
    animatorSet.play(bouncer).before(fadeAnim)
    animatorSet.start()

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

    ViewPropertyAnimator


    Последний класс — ViewPropertyAnimator. Он является одним из лучших классов для анимирования View. Это отличный API для введения последовательности запускаемых вами анимаций:

    ViewCompat.animate(textView)
           .translationX(50f)
           .translationY(100f)
           .setDuration(1000)
           .setInterpolator(AccelerateDecelerateInterpolator())
           .setStartDelay(50)
           .setListener(object : Animator.AnimatorListener {
               override fun onAnimationRepeat(animation: Animator) {}
               override fun onAnimationEnd(animation: Animator) {}
               override fun onAnimationCancel(animation: Animator) {}
               override fun onAnimationStart(animation: Animator) {}
           })
    

    Запускаем метод ViewCompat.animate, который возвращает ViewPropertyAnimator, и для анимирования translationX задаём значение 50, для параметра translatonY — 100. Затем указываем длительность анимации, а также интерполятор. Интерполятор определяет последовательность, в которой будут появляться анимации. В данном примере используется интерполятор, который ускоряет начало анимации и добавляет замедление в конце. Также добавляем задержку для старта анимации. Кроме этого, у нас есть AnimatorListener. С его помощью можно подписываться на определённые события, которые возникают во время выполнения анимации. У данного интерфейса есть 4 метода: onAnimationStart, onAnimationCancel, onAnimationEnd, onAnimationRepeat.

    Как правило, нас интересует только завершение анимации. В API Level 16
    добавили withEndAction:

    .withEndAction({ //API 16+
       //do something here where animation ends
    })

    В ней можно определить интерфейс Runnable, и после завершения показа конкретной анимации выполнится действие.

    Теперь несколько замечаний по поводу процесса создания анимаций в целом:

    1. Метод start() не обязателен: как только вы вызываете метод animate(), вводится последовательность анимаций. Когда ViewPropertyAnimator будет настроен, система запустит анимацию сразу, как будет готова это сделать.
    2. Только один класс ViewPropertyAnimator может анимировать только конкретный View. Поэтому если вы хотите выполнять несколько анимаций, например, вам хочется, чтобы что-то двигалось, и при этом увеличивалось в размерах, то нужно указать это в одном аниматоре.

    Почему мы выбрали RxJava?


    Начнём с простого примера. Предположим, мы создаем метод fadeIn:

    fun fadeIn(view: View, duration: Long): Completable {
       val animationSubject = CompletableSubject.create()
       return animationSubject.doOnSubscribe {
           ViewCompat.animate(view)
                   .setDuration(duration)
                   .alpha(1f)
                   .withEndAction {
                       animationSubject.onComplete()
                   }
       }
    }

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

    Мы собираемся создать CompletableSubject, который будем использовать, чтобы дожидаться завершения анимаций, а затем при помощи метода onComplete отправлять сообщения подписчикам. Для последовательного запуска анимаций необходимо стартовать анимацию не сразу, а как только на неё кто-то подпишется. Таким образом можно последовательно запускать несколько анимации в реактивном стиле.

    Рассмотрим саму анимацию. В ней передаём View, над которой будет совершаться анимация, а также указываем длительность анимации. И поскольку это анимация — появление, то мы должны указать прозрачность 1.

    Попробуем использовать наш метод и создадим простую анимацию. Предположим, у нас на экране есть 4 кнопки, и мы хотим добавить для них анимацию появления длительностью 1 секунду:

    val durationMs = 1000L
    
    button1.alpha = 0f
    button2.alpha = 0f
    button3.alpha = 0f
    button4.alpha = 0f
    
    fadeIn(button1, durationMs)
           .andThen(fadeIn(button2, durationMs))
           .andThen(fadeIn(button3, durationMs))
           .andThen(fadeIn(button4, durationMs))
           .subscribe()
    

    В результате получается вот такой лаконичный код. С помощью оператора andThen можно запускать анимации последовательно. Когда мы подпишемся на него, он отправит событие doOnSubscribe к Completable, который стоит первым в очереди на исполнение. После его завершения он подпишется ко второму, третьему, и так по цепочке. Поэтому если на каком-то этапе появляется ошибка, то вся последовательность выдаёт ошибку. Нужно также указать значение alpha 0 до начало анимации, чтобы кнопки были невидимыми. И вот как это будет выглядеть:


    Используя Kotlin, то мы можем использовать расширения:

    fun View.fadeIn(duration: Long): Completable {
       val animationSubject = CompletableSubject.create()
       return animationSubject.doOnSubscribe {
           ViewCompat.animate(this)
                   .setDuration(duration)
                   .alpha(1f)
                   .withEndAction {
                       animationSubject.onComplete()
                   }
       }
    }

    Для класса View добавили функцию-расширение. В дальнейшем нет необходимости передавать в метод fadeIn аргумент View. Теперь можно внутри метода заменить все обращения к View на ключевое слово this. Это то, на что способен язык Kotlin.

    Посмотрим, как изменился вызов этой функции в нашей цепочке анимаций:

    button1.fadeIn(durationMs)
           .andThen(button2.fadeIn(durationMs))
           .andThen(button3.fadeIn(durationMs))
           .andThen(button4.fadeIn(durationMs))
           .subscribe()
    

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

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

    fun View.fadeIn(duration: Long = 1000L):

    Если не указывать параметр duration, то время автоматически будет установлено как 1 секунда. Но если мы захотим для кнопки под номером 2 увеличить это время до 2 секунд, мы просто указываем это значение в методе:

    button1.fadeIn()
           .andThen(button2.fadeIn(duration = 2000L))
           .andThen(button3.fadeIn())
           .andThen(button4.fadeIn())
           .subscribe()
    

    Запуск двух анимаций


    Мы смогли запустить последовательность из анимаций с помощью оператора andThen. Что делать, если понадобится запустить одновременно 2 анимации? Для этого в RxJava существует оператор mergeWith, который позволяет объединять элементы Completable таким образом, что они будут запускаться одновременно. Этот оператор запускает все элементы и заканчивает работу после того, как будет показан последний элемент. Если поменять andThen на mergeWith, то получим анимацию, в которой все кнопки появляются одновременно, но кнопка 2 будет появляться немного дольше остальных:

    button1.fadeIn()
           .mergeWith(button2.fadeIn(2000))
           .mergeWith(button3.fadeIn())
           .mergeWith(button4.fadeIn())
           .subscribe()


    Теперь мы можем группировать анимации. Попробуем усложнить задачу: например, мы хотим, чтобы сначала одновременно появились кнопка 1 и кнопка 2, а затем — кнопка 3 и кнопка 4:

    (button1.fadeIn().mergeWith(button2.fadeIn()))
           .andThen(button3.fadeIn().mergeWith(button4.fadeIn()))
           .subscribe()

    Объединяем первую и вторую кнопки оператором mergeWith, повторяем действие для третьей и четвертой, и запускаем эти группы последовательно с помощью оператора andThen. Теперь улучшим код, добавив метод fadeInTogether:

    fun fadeInTogether(first: View, second: View): Completable {
       return first.fadeIn()
               .mergeWith(second.fadeIn())
    }

    Он позволит запускать анимацию fadeIn для двух View одновременно. Как изменилась цепочка анимаций:

    fadeInTogether(button1, button2)
           .andThen(fadeInTogether(button3, button4))
           .subscribe()

    В итоге получится следующая анимация:


    Рассмотрим более сложный пример. Предположим, нам нужно показывать анимацию с некоторой заданной задержкой. В этом поможет оператор interval:

    fun animate() {
       val timeObservable = Observable.interval(100, TimeUnit.MILLISECONDS)
       val btnObservable = Observable.just(button1, button2, button3, button4)
    }

    Он будет генерировать значения каждые 100 миллисекунд. Каждая кнопка будет появляться спустя 100 миллисекунд. Далее указываем ещё один Observable, который будет эмитить кнопки. В данном случае у нас 4 кнопки. Воспользуемся оператором zip.

    image

    Перед нами потоки событий:

    Observable.zip(timeObservable, btnObservable,
           BiFunction<Long, View, Disposable> { _, button ->
               button.fadeIn().subscribe()
           })

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

    Второй Observable будет генерировать view. Оператор zip ждёт, пока первый объект появится в первом потоке, и соединяет его с первым объектом из второго потока. Несмотря на то, что все эти 4 объекта во втором потоке появятся сразу, он будет ждать, пока объекты начнут появляться на первом потоке. Таким образом, первый объект из первого потока будет соединяться с первым объектом из второго в виде нашего view, а 100 миллисекунд спустя, когда появится новый объект, оператор объединит его со вторым объектом. Поэтому view будут появляться с опеределённой задержкой.

    Разберёмся с BiFinction в RxJava. Эта функция получает на вход два объекта, делает какие-то операции над ними и возвращает третий объект. Мы хотим взять объекты time и view и получить Disposable за счёт того, что мы вызываем анимацию fadeIn и подписываемся subscribe. Значение time нам не важно. В итоге получаем вот такую анимацию:


    VanGogh


    Расскажу про проект, который Иван начал разрабатывать для MBLT DEV 2017.

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

    Рассмотрим библиотеку на примере:

    fun fadeIn(view:View) : AnimationCompletable {
       return AnimationBuilder.forView(view)
               .alpha(1f)
               .duration(2000L)
               .build().toCompletable()
    }

    Предположим, вы хотите создать появляющуюся анимацию, но в этот раз вместо объекта Completable появляется AnimationCompletable. Данный класс наследуется от Completable, так что теперь появляется больше функций. Одной важной особенностью предыдущего кода было то, что отменять анимации было нельзя. Теперь можно создать объект AnimationCompletable, который заставляет анимацию остановиться, как только мы отпишемся от неё.

    Создаём появляющуюся анимацию с помощью AnimationBuilder — один из классов библиотеки. Указываем, к какой View будет применена анимация. По сути этот класс копирует поведение ViewPropertyAnimator, но с разницей в том, что на выходе получаем поток.

    Далее выставляем alpha 1f и длительность 2 секунды. Затем cобираем анимацию. Как только вызываем оператор build, появляется анимация. Присваиваем анимации свойство не изменяемого объекта, поэтому он сохранит эти характеристики для её запуска. Но сама анимация не запустится.

    Вызываем toCompletable, который создаст AnimationCompletable. Обернёт параметры этой анимации в своеобразную оболочку для реактивного программирования, и как только вы подпишетесь на него — запустит анимацию. Если отключить его до завершения процесса, анимация закончится. Также теперь можно добавить функцию обратного вызова. Можно прописать операторы doOnAnimationReady, doOnAnimationStart, doOnAnimationEnd и тому подобное:

    fun fadeIn(view:View) : AnimationCompletable {
       return AnimationBuilder.forView(view)
               .alpha(1f)
               .duration(2000L)
               .buildCompletable()
               .doOnAnimationReady { view.alpha = 0f }
    }

    В этом примере мы показали, как удобно использовать AnimationBuilder, и изменили состояние нашей View перед запуском анимации.

    Видеозапись доклада


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

    Помимо расшифровки делюсь видеозаписью доклада:


    Спикеры MBLT DEV 2018


    До MBLT DEV 2018 осталось чуть больше двух месяцев. У нас выступят:

    • Laura Morinigo, Google Developer Expert
    • Kaushik Gopal, автор подкаста Fragmented
    • Артём Рудой, Badoo
    • Дина Сидорова, Google, и другие.

    Уже завтра стоимость билета изменится. Регистрируйся сегодня.

    e-Legion

    83,00

    Лидер мобильной разработки в России

    Поделиться публикацией

    Похожие публикации

    Комментарии 5
      –1

      Почему вы использовали RxJava вместо Kotlin Channels?


      Вторые лучше, так как:


      1. Они быстрее (ибо используют встроеные в язык возможности — coroutines, inlining и тд)
      2. В них удобнее операторы (так как были разработаны для Kotlin)
      3. В них нет проблем, связанной с обратной совместимостью Rx Java
        +1

        Почему "вместо"? Подразумевается, что был выбор между Kotlin Channels и RxJava?


        Можно я отвечу? Coroutines — экспериментальная функция, RxJava — стабильное проверенное временем решение. Этого достаточно, чтобы не использовать Kotlin Channels.

          0
          > Coroutines — экспериментальная функция

          Экспериментальное с точки зрения именования классов, функций и прочего в библиотеке kotlinx.coroutines. Сами корутины протестированы достаточно (как с точки зрения юнит-тестов, так и проверены временем и продакшеном). И да, пользоваться kotlinx.coroutines гораздо удобнее в котлине, чем RxJava/RxKotlin. Ну, как минимум для меня. Хотя никто не запрещает, это да.
            +2
            Coroutines — экспериментальная функция

            В 1.3 перестанет быть экспериментальной. Первый milestone как раз на днях вышел: https://blog.jetbrains.com/kotlin/2018/07/see-whats-coming-in-kotlin-1-3-m1

            +1
            Возможно немного поздновато… но отвечу
            1. возможно coroutines быстрее, но на момент выхода данного доклада они были в экспериментальной версии (в начале статьи указано что это адаптация доклада).
            2. что они удобнее здесь сложно сказать, на мой взгляд дело больше вкуса чем какой то принципиальной разницы.
            3. здесь немного не понял… если ты про проблемы типизации? то я много видел issue у RxJava где все же эти проблемы решаются.

            Но как сказал сам спикер: он приветствует любые вопросы и предложения в его проекте.

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

          Самое читаемое