Как сделать анимацию с помощью MotionLayout

Привет, Хабр! Меня зовут Павел Беловол, я Android-разработчик на проекте онлайн-кинотеатра KION в МТС Digital. Это новая часть сериала о внедрении фичи Autoplay в KION, в которой я расскажу про свой личный опыт работы с MotionLayout на примере продакшн-задачи в KION. Из этой статьи вы узнаете, где нужно использовать MotionLayout, а где лучше обойтись без него и писать код анимации самостоятельно.

82cbbda717ba57bf1c31fe83adf17e63.jpg

Небольшая вводная:

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

А теперь поговорим о нашей фиче.

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

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

Обсудив эту задачу, мы пришли к выводу, что нам нужно следующее:

  • api, которое вернет похожий фильм;

  • доработка на клиенте, которая будет предлагать пользователю фильм, полученный от api.

Я не буду раскрывать подробности реализации api, это тема для отдельной статьи. Сейчас просто примем во внимание, что аpi у нас функционирует и находит лучший подобный фильм.

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

Анимация. Желаемый результатАнимация. Желаемый результат

О том, что пользователь досмотрел фильм до финальных титров, мы узнаем от api. И есть слушатель в коде, который сообщит, что настала пора играть анимацию.

Немного расскажу про кнопки. При нажатии на «Смотреть титры» пользователя должно вернуть назад к просмотру фильма.

Возврат пользователя к просмотру фильма по нажатию кнопки «Смотреть титры»Возврат пользователя к просмотру фильма по нажатию кнопки «Смотреть титры»

По нажатию на маленькое окошко с плеером нужно выполнить возврат к просмотру титров, то есть это действие равносильно нажатию на кнопку «Смотреть титры».

По нажатию на кнопку «Следующий фильм» или по окончании обратного отсчета включится следующий похожий фильм.

По нажатию на кнопку «X» выполняется выход из экрана с плеером. 

С кликами разобрались, теперь декомпозируем анимацию:

  1. Текущее окошко плеера уменьшается и перемещается в левый нижний угол;

  2. В качестве фона устанавливается постер следующего похожего фильма;

  3. Появляется кнопка «Х» в правом верхнем углу;

  4. Снизу выезжает блок с названием, жанром и кнопками «Смотреть титры» и «Следующий фильм»;

  5. Кнопка «Следующий фильм» имеет обратный отсчет по окончании которого фильм включится автоматически, если пользователь не предпринял никаких действий.

Пункты 1–4 мы будем анимировать полностью с помощью MotionLayout, пункт 5 будем анимировать частично с помощью MotionLayout, частично — вручную. Чуть позже объясню, почему так.

От теории к практике:

Начнем с самого простого, создадим xml-файл activity_main.xml с разметкой наших виджетов.




    

    

    

    


    

    

    

    

Краткое пояснение по идентификаторам :

  • motionLayout — контейнер выполняющий анимацию

  • poster — постер следующего фильма

  • shadow — затемнение

  • close — иконка «X»

  • filmTitle — текст с названием фильма

  • filmInfo — текст с описанием фильма

  • watchCredits — кнопка с названием «Смотреть титры»

  • nextFilm — кнопка с названием «Следующий фильм»

  • player — картинка с имитацией воспроизведения плеера (в продакшн-коде вместо imageView, как правило, контейнер, в который добавляется плеер. Например, FrameLayout).

Также создадим файл сцены scene.xml, именно в нем мы будем описывать, как нужно анимировать виджеты.




    
        
    

    

       
    

    

Краткое пояснение по идентификаторам :

  • start — начальное состояние анимации;

  • end — конечное состояние анимации;

  • transition — переход между состояниями start и end, параметр duration (длительность анимации) установим в 500 миллисекунд.

Результат в Android StudioРезультат в Android Studio

Если мы попытаемся проиграть сцену в Android Studio, то ничего не произойдет, так как наши constraintSet пустые, и нет никаких условий анимации. Давайте исправим это.

Полный файл scene.xml



  
    

        
        
        
        
        
    

    

        
        
        
        
        
        
        
        
    

    

В ConstraintSet с идентификатором start мы описали свойства виджетов в начальном состоянии анимации, а с идентификатором end, — свойства виджетов в конечном состоянии анимации.

Результат воспроизведения в Android studio на скорости 0.25x (чтобы плавнее увидеть переход)Результат воспроизведения в Android studio на скорости 0.25x (чтобы плавнее увидеть переход)

Задача практически выполнена, но мы забыли про одну вещь — кнопка «Следующая серия» должна анимироваться, наполняясь индикатором прогресса. Если до этого мы анимировали элементы с помощью MotionLayout, то в этот раз мы должны поступить иначе, так как кнопка с прогрессом — кастомный элемент, и у нас не получится стандартными атрибутами сцены выполнить анимацию заполнения прогресса.

В этом случае нам придется написать код анимации самостоятельно.

Код кнопки

import android.animation.ValueAnimator
import android.content.Context
import android.os.Parcel
import android.os.Parcelable
import android.util.AttributeSet
import android.view.Gravity
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.core.animation.addListener
import androidx.core.content.ContextCompat
import androidx.core.content.res.use

class ProgressButton @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : CardView(context, attrs, defStyleAttr) {

    var onCountDown: (() -> Unit)? = null
    private var clickListener: OnClickListener? = null

    private val textView = TextView(context).apply {
        gravity = Gravity.CENTER
        setTextColor(
            ContextCompat.getColor(context, R.color.progress_button_text_color)
        )
        isAllCaps = false
        layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
            setPadding(0, 0, 12.toPx, 0)
        }
    }
    private val imageView = ImageView(context).apply {
        setImageResource(R.drawable.ic_player_next)
        layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
    }

    private val progressBar =
        ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal).apply {
            max = 100
            progress = if (isInEditMode) 70 else 0
            isIndeterminate = false
            layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
            progressDrawable = ContextCompat.getDrawable(
                context, R.drawable.next_episode_progress_bar_states
            )
        }


    private val animator = ValueAnimator.ofInt(0, 100).apply {
        addUpdateListener {
            progressBar.progress = it.animatedValue as Int
        }
        addListener(
            onEnd = {
                if (animatedValue == 100) {
                    onCountDown?.invoke()
                }
            }
        )
        duration = 7000
    }

    fun startProgress() {
        if (!animator.isRunning) {
            animator.start()
        }
    }

    fun cancelProgress() {
        animator.cancel()
    }

    init {
        addView(progressBar)
        addView(
            LinearLayout(context).apply {
                orientation = LinearLayout.HORIZONTAL
                layoutParams =
                    LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply {
                        gravity = Gravity.CENTER
                    }
                gravity = Gravity.CENTER
                addView(textView)
                addView(imageView)
            }
        )
        radius = 7.toPx.toFloat()
        super.setOnClickListener {
            animator.cancel()
            clickListener?.onClick(it)
        }
        setBackgroundResource(R.drawable.next_episode_button_stroke)
        context.theme.obtainStyledAttributes(
            attrs,
            R.styleable.PlayerNextEpisodeButton,
            0, 0
        ).use {
            textView.text = it.getString(R.styleable.PlayerNextEpisodeButton_text)
        }
        isSaveEnabled = true
    }

    override fun setOnClickListener(l: OnClickListener?) {
        clickListener = l
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        cancelProgress()
        onCountDown = null
    }

    override fun onSaveInstanceState(): Parcelable? {
        val superState = super.onSaveInstanceState()
        superState?.let {
            val state = SavedState(superState)
            state.progressBarState = progressBar.onSaveInstanceState()
            state.isProgressRunning = animator.isRunning
            state.currentPlayTime = animator.currentPlayTime
            return state
        } ?: run {
            return superState
        }
    }

    override fun onRestoreInstanceState(state: Parcelable?) {
        when (state) {
            is SavedState -> {
                super.onRestoreInstanceState(state.superState)
                progressBar.onRestoreInstanceState(state.progressBarState)
                if (state.isProgressRunning) {
                    animator.currentPlayTime = state.currentPlayTime
                    startProgress()
                }
            }
            else -> {
                super.onRestoreInstanceState(state)
            }
        }
    }

    private class SavedState : BaseSavedState {
        var progressBarState: Parcelable? = null
        var isProgressRunning = false
        var currentPlayTime = 0L

        constructor(parcel: Parcel) : super(parcel) {
            progressBarState = parcel.readParcelable(ProgressBar::class.java.classLoader)
            isProgressRunning = parcel.readInt() == 1
            currentPlayTime = parcel.readLong()
        }

        constructor (parcelable: Parcelable?) : super(parcelable)

        override fun writeToParcel(out: Parcel?, flags: Int) {
            super.writeToParcel(out, flags)
            out?.writeParcelable(progressBarState, flags)
            out?.writeInt(if (isProgressRunning) 1 else 0)
            out?.writeLong(currentPlayTime)
        }

        companion object {
            @Suppress("unused")
            @JvmField
            val CREATOR = object : Parcelable.Creator {
                override fun createFromParcel(source: Parcel): SavedState {
                    return SavedState(source)
                }

                override fun newArray(size: Int): Array {
                    return arrayOfNulls(size)
                }
            }
        }
    }

}

Мы написали код кнопки с прогрессом, в качестве анимирования использовали стандартный ValueAnimator, учитывая сохранение состояния после смены конфигурации.

Ключевые методы:

  • startProgress() — запускает анимацию заполнения прогресса

  • cancelProgress() — останавливает анимацию заполнения прогресса

Результат на реальном устройствеРезультат на реальном устройстве

Теперь остается самая важная задача — запустить общую анимацию. 

Тут все просто.

motionLayout.transitionToEnd() — запускает анимацию начиная с состояния start к end

motionLayout.transitionToStart() — запускает анимацию начиная с состояния end к start

Теперь добавим код запуска к слушателям кнопок.

close.setOnClickListener {
    nextFilm.cancelProgress()
}
player.setOnClickListener {
    motionLayout.transitionToStart()
    nextFilm.cancelProgress()
}
watchCredits.setOnClickListener {
    motionLayout.transitionToStart()
    nextFilm.cancelProgress()
}
nextFilm.setOnClickListener {
    motionLayout.transitionToStart()
    nextFilm.cancelProgress()
}
nextFilm.onCountDown = {
    nextFilm.performClick()
}

Так как кнопка progressButton — это кастомный элемент, который анимируется частично самостоятельно, нужно отдельно вызывать startProgress (), cancelProgress ().

И добавим код запуска анимации по достижению титров.

nextFilm.startProgress()

motionLayout.transitionToEnd()

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

Исправим это:

class MainViewModel : ViewModel() {
    var motionLayoutState : Bundle? = null
}

class MainActivity : AppCompatActivity() {

    private val viewModel by viewModels()
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        viewModel.motionLayoutState?.let {
            binding.motionLayout.transitionState = it
        }
    }

    override fun onSaveInstanceState(outState: Bundle) {
        viewModel.motionLayoutState = binding.motionLayout.transitionState
        super.onSaveInstanceState(outState)
    }
}

Мы сохранили состояние MotionLayout с помощью viewModel, теперь сцена до и после поворота отображается корректно. Напомню, что progressButton уже содержит в себе код сохранения и восстановления состояния.

Финальный результат на реальном устройстве:

Каков итог?

MotionLayout — мощный и удобный инструмент для написания анимаций на Android, но не всегда и не все получится анимировать c его помощью. Иногда придется писать код анимации самостоятельно, как это сделал я для кнопки с заполнением прогресса.

Спасибо за уделенное время! Если у вас есть вопросы, замечания или истории из личного опыта работы с MotionLayout — добро пожаловать в комментарии.

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

Про саму фичу Autoplay в онлайн-кинотеатре

Про нюансы реализации фичи на tvOS

Про разработку фичи на Angular под SmartTV

А еще мы рассказывали на Хабре про другую фичу KION, реализованную с помощью искусственного интеллекта — пропуск титров.

Подробно про саму фичу

Про проблемы и их решения с помощью Computed Properties в Angular

© Habrahabr.ru