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

от автора

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

<?xml version="1.0" encoding="utf-8"?> <androidx.constraintlayout.motion.widget.MotionLayout xmlns:android="http://schemas.android.com/apk/res/android"     xmlns:app="http://schemas.android.com/apk/res-auto"     xmlns:tools="http://schemas.android.com/tools"     android:id="@+id/motionLayout"     android:layout_width="match_parent"     android:layout_height="match_parent"     app:layoutDescription="@xml/scene"     tools:context=".MainActivity">      <ImageView         android:id="@+id/poster"         android:layout_width="match_parent"         android:layout_height="match_parent"         android:scaleType="centerCrop"         app:srcCompat="@drawable/poster"         tools:ignore="ContentDescription" />      <View         android:id="@+id/shadow"         android:layout_width="0dp"         android:layout_height="0dp"         app:layout_constraintBottom_toBottomOf="parent"         app:layout_constraintEnd_toEndOf="parent"         app:layout_constraintStart_toStartOf="parent"         app:layout_constraintTop_toTopOf="parent"         android:background="#99000000"         tools:ignore="ContentDescription" />      <ImageView         android:id="@+id/close"         android:layout_width="wrap_content"         android:layout_height="wrap_content"         android:layout_marginTop="24dp"         android:layout_marginEnd="24dp"         android:padding="16dp"         app:layout_constraintEnd_toEndOf="parent"         app:layout_constraintTop_toTopOf="parent"         app:srcCompat="@drawable/ic_close_24"         tools:ignore="ContentDescription,ImageContrastCheck" />      <TextView         android:id="@+id/filmTitle"         android:layout_width="0dp"         android:layout_height="wrap_content"         android:layout_marginStart="24dp"         android:layout_marginEnd="24dp"         android:layout_marginBottom="12dp"         android:gravity="end"         android:text="@string/vod_title"         android:textColor="#EEEEEE"         android:textSize="24sp"         app:layout_constraintBottom_toTopOf="@id/filmInfo"         app:layout_constraintEnd_toEndOf="parent"         app:layout_constraintStart_toEndOf="@+id/player"         app:layout_constraintTop_toTopOf="parent"         app:layout_constraintVertical_bias="1.0"         app:layout_constraintVertical_chainStyle="packed" />       <TextView         android:id="@+id/filmInfo"         android:layout_width="0dp"         android:layout_height="wrap_content"         android:layout_marginStart="24dp"         android:layout_marginEnd="24dp"         android:layout_marginBottom="32dp"         android:gravity="end"         android:text="@string/vod_detail"         android:textColor="#B2C6DB"         android:textSize="14sp"         app:layout_constraintBottom_toTopOf="@id/nextFilm"         app:layout_constraintEnd_toEndOf="parent"         app:layout_constraintStart_toEndOf="@+id/player"         app:layout_constraintTop_toBottomOf="@+id/filmTitle"         app:layout_constraintVertical_chainStyle="packed" />      <androidx.appcompat.widget.AppCompatButton         android:id="@+id/watchCredits"         android:layout_width="wrap_content"         android:layout_height="44dp"         android:layout_marginEnd="10dp"         android:alpha="0.8"         android:background="@drawable/bg_credits"         android:gravity="center"         android:paddingLeft="20dp"         android:paddingTop="13dp"         android:paddingRight="20dp"         android:paddingBottom="13dp"         android:text="@string/watch_credits"         android:textAllCaps="false"         android:textColor="@android:color/white"         android:textSize="12sp"         android:visibility="visible"         app:layout_constraintBottom_toBottomOf="@+id/nextFilm"         app:layout_constraintEnd_toStartOf="@id/nextFilm"         app:layout_constraintTop_toTopOf="@id/nextFilm"         app:lineSpacing="2sp"         tools:ignore="TouchTargetSizeCheck" />      <com.example.motionlayoutsample.ProgressButton         android:id="@+id/nextFilm"         android:layout_width="202dp"         android:layout_height="44dp"         android:layout_marginEnd="24dp"         android:layout_marginBottom="80dp"         android:paddingLeft="20dp"         android:paddingTop="13dp"         android:paddingRight="20dp"         android:paddingBottom="13dp"         app:layout_constraintBottom_toBottomOf="parent"         app:layout_constraintEnd_toEndOf="parent"         app:layout_constraintTop_toBottomOf="@id/filmInfo"         app:text="@string/next_film"         tools:ignore="TouchTargetSizeCheck" />      <ImageView         android:id="@+id/player"         android:layout_width="257dp"         android:layout_height="140dp"         android:src="@drawable/content"         android:padding="2dp"         android:layout_marginStart="24dp"         android:layout_marginBottom="80dp"         android:background="@drawable/bg_player"         app:layout_constraintBottom_toBottomOf="parent"         app:layout_constraintStart_toStartOf="parent"         tools:ignore="ContentDescription" />  </androidx.constraintlayout.motion.widget.MotionLayout>

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

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

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

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

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

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

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

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

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

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

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

<?xml version="1.0" encoding="utf-8"?> <MotionScene xmlns:android="http://schemas.android.com/apk/res/android"     xmlns:app="http://schemas.android.com/apk/res-auto">      <ConstraintSet android:id="@+id/start">              </ConstraintSet>      <ConstraintSet android:id="@+id/end">              </ConstraintSet>      <Transition         android:id="@+id/transition"         app:duration="500"         app:constraintSetEnd="@id/end"         app:constraintSetStart="@+id/start" />  </MotionScene>

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

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

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

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

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

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

Полный файл scene.xml
<?xml version="1.0" encoding="utf-8"?> <MotionScene xmlns:android="http://schemas.android.com/apk/res/android"     xmlns:app="http://schemas.android.com/apk/res-auto">        <ConstraintSet android:id="@+id/start">          <Constraint             android:id="@+id/player"             android:layout_width="0dp"             android:layout_height="0dp"             app:layout_constraintStart_toStartOf="parent"             app:layout_constraintEnd_toEndOf="parent"             app:layout_constraintBottom_toBottomOf="parent"             app:layout_constraintTop_toTopOf="parent" />         <Constraint             android:id="@+id/filmTitle"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="wrap_content"             android:layout_height="wrap_content"             android:layout_marginEnd="24dp"             app:layout_constraintVertical_chainStyle="packed"             android:layout_marginBottom="12dp"             app:layout_constraintBottom_toTopOf="@id/filmInfo"             app:layout_constraintVertical_bias="1.0"             app:layout_constraintTop_toBottomOf="parent" />         <Constraint             android:layout_marginEnd="24dp"             android:layout_height="wrap_content"             android:layout_marginBottom="32dp"             app:layout_constraintBottom_toTopOf="@id/nextFilm"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="wrap_content"             app:layout_constraintTop_toBottomOf="@+id/filmTitle"             app:layout_constraintVertical_chainStyle="packed"             android:id="@+id/filmInfo" />         <Constraint             android:id="@+id/shadow"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="0dp"             android:layout_height="0dp"             app:layout_constraintBottom_toBottomOf="parent"             app:layout_constraintTop_toTopOf="parent"             app:layout_constraintStart_toStartOf="parent"             android:visibility="invisible" />         <Constraint             android:id="@+id/close"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="wrap_content"             android:layout_height="wrap_content"             android:layout_marginEnd="28dp"             app:layout_constraintTop_toTopOf="parent"             android:layout_marginTop="28dp"             android:visibility="invisible" />     </ConstraintSet>      <ConstraintSet android:id="@+id/end">          <Constraint             android:id="@+id/poster"             android:layout_width="match_parent"             android:layout_height="match_parent" />         <Constraint             android:id="@+id/shadow"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="0dp"             android:layout_height="0dp"             app:layout_constraintBottom_toBottomOf="parent"             app:layout_constraintTop_toTopOf="parent"             app:layout_constraintStart_toStartOf="parent"             android:visibility="visible" />         <Constraint             android:id="@+id/close"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="wrap_content"             android:layout_height="wrap_content"             android:layout_marginEnd="28dp"             app:layout_constraintTop_toTopOf="parent"             android:layout_marginTop="28dp"             android:visibility="visible" />         <Constraint             android:id="@+id/filmTitle"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="0dp"             android:layout_height="wrap_content"             android:layout_marginEnd="24dp"             android:layout_marginStart="24dp"             app:layout_constraintVertical_chainStyle="packed"             app:layout_constraintTop_toTopOf="parent"             app:layout_constraintStart_toEndOf="@+id/player"             android:layout_marginBottom="12dp"             app:layout_constraintBottom_toTopOf="@id/filmInfo"             app:layout_constraintVertical_bias="1.0" />         <Constraint             android:id="@+id/filmInfo"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="0dp"             android:layout_height="wrap_content"             android:layout_marginEnd="24dp"             android:layout_marginStart="24dp"             app:layout_constraintVertical_chainStyle="packed"             app:layout_constraintStart_toEndOf="@+id/player"             android:layout_marginBottom="32dp"             app:layout_constraintBottom_toTopOf="@id/nextFilm"             app:layout_constraintTop_toBottomOf="@+id/filmTitle" />         <Constraint             android:id="@+id/watchCredits"             android:layout_width="wrap_content"             android:layout_height="44dp"             android:visibility="visible"             android:layout_marginEnd="10dp"             app:layout_constraintTop_toTopOf="@id/nextFilm"             app:layout_constraintEnd_toStartOf="@id/nextFilm"             app:layout_constraintBottom_toBottomOf="@+id/nextFilm"             android:alpha="0.8" />         <Constraint             android:id="@+id/nextFilm"             app:layout_constraintEnd_toEndOf="parent"             android:layout_width="202dp"             android:layout_height="44dp"             android:layout_marginEnd="24dp"             app:layout_constraintBottom_toBottomOf="parent"             android:layout_marginBottom="80dp"             app:layout_constraintTop_toBottomOf="@id/filmInfo" />         <Constraint             android:id="@+id/player"             android:layout_width="257dp"             android:layout_height="140dp"             app:layout_constraintBottom_toBottomOf="parent"             android:layout_marginBottom="80dp"             android:layout_marginStart="24dp"             app:layout_constraintStart_toStartOf="parent" />     </ConstraintSet>      <Transition         android:id="@+id/transition"         app:duration="500"         app:constraintSetEnd="@id/end"         app:constraintSetStart="@+id/start" />  </MotionScene>

В 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<SavedState> {                 override fun createFromParcel(source: Parcel): SavedState {                     return SavedState(source)                 }                  override fun newArray(size: Int): Array<SavedState?> {                     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<MainViewModel>()     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


ссылка на оригинал статьи https://habr.com/ru/company/ru_mts/blog/709958/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *