company_banner

Анимация в Android: плавные переходы фрагментов внутри Bottom Sheet

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

    Данная статья о проблеме и анализе вариантов её решения. Я не дам вам серебряную пулю против всех монстров, но покажу, как можно изучить конкретного, чтобы создать пулю специально для него. Разберу это на примере того, как мы подружили анимацию смены фрагментов с Bottom Sheet.



    Бриллиантовый чекаут: предыстория


    Бриллиантовый чекаут — кодовое название нашего проекта. Смысл его очень прост — сократить время, затрачиваемое клиентом на последнем этапе оформления заказа. Если в старой версии для оформления заказа требовалось минимум четыре клика на двух экранах (а каждый новый экран — это потенциальная потеря контекста пользователем), «бриллиантовый чекаут» в идеальном случае требует всего один клик на одном экране.


    Сравнение старого и нового чекаута

    Между собой мы называем новый экран «шторка». На рисунке вы видите, в каком виде мы получили задание от дизайнеров. Данное дизайнерское решение является стандартным, известно оно под именем Bottom Sheet, описано в Material Design (в том числе для Android) и в разных вариациях используется во многих приложениях. Google предлагает нам два готовых варианта реализации: модальный (Modal) и постоянный (Persistent). Разница между этими подходами описана во многих и многих статьях.


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

    Смотри, какая классная анимация на iOS. Давай так же сделаем?


    Такой вызов не принять мы не могли! Ладно, шучу по поводу «дизайнеры неожиданно пришли с предложением сделать анимацию», но часть про iOS — чистая правда.

    Стандартные переходы между экранами (то есть, отсутствие переходов) выглядели хоть и не слишком коряво, но до соответствия званию «бриллиантовый чекаут» не дотягивали. Хотя, кого я обманываю, это действительно было ужасно:


    Что имеем «из коробки»

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

    1. Клиент нажимал на поле адреса пиццерии -> в ответ открывался фрагмент «Самовывоз». Открывался он на весь экран (так было задумано) с резким скачком, при этом список пиццерий появлялся с небольшой задержкой.
    2. Когда клиент нажимал «Назад» -> возврат на предыдущий экран происходил с резким скачком.
    3. При нажатии на поле способа оплаты -> снизу с резким скачком открывался фрагмент «Способ оплаты». Список способов оплаты появлялся с задержкой, при их появлении экран увеличивался со скачком.
    4. При нажатии «Назад» -> возврат обратно с резким скачком.

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

    В чём, собственно, проблема: где клиенту хорошо, там у нас ограничения


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

    Это привело нас к техническому ограничению: мы решили, что нам нельзя на каждую смену экрана закрывать текущий bottom sheet и показывать новый, а также будет плохо показывать несколько bottom sheet один над другим. Так, в рамках нашей реализации (каждый экран — новый фрагмент), можно сделать только один bottom sheet, который должен двигаться максимально плавно в ответ на действия пользователей.

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

    Предварительная разметка


    Корневой элемент «шторки» очень простой — это всего лишь прямоугольный фон с закруглёнными сверху углами и контейнер, в который помещаются фрагменты.

    <?xml version="1.0" encoding="utf-8"?>
    <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/dialog_gray200_background"
        >
     
      <androidx.fragment.app.FragmentContainerView
          android:id="@+id/container"
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          />
     
    </FrameLayout>

    И файл dialog_gray200_background.xml выглядит так:

    <?xml version="1.0" encoding="utf-8"?>
    <selector xmlns:android="http://schemas.android.com/apk/res/android">
      <item>
        <shape android:shape="rectangle">
          <solid android:color="@color/gray200" />
          <corners android:bottomLeftRadius="0dp" android:bottomRightRadius="0dp" android:topLeftRadius="10dp" android:topRightRadius="10dp" />
        </shape>
      </item>
    </selector>

    Каждый новый экран представляет собой отдельный фрагмент, фрагменты сменяются с помощью метода replace, тут всё стандартно.

    Первые попытки реализовать анимацию


    animateLayoutChanges


    Вспоминаем о древней эльфийской магии animateLayoutChanges, которая на самом деле представляет собой дефолтный LayoutTransition. Хотя animateLayoutChanges совершенно не рассчитан на смену фрагментов, есть надежда, что это поможет с анимацией высоты. Также FragmentContainerView не поддерживает animateLayoutChanges, поэтому меняем его на старый добрый FrameLayout.

    <?xml version="1.0" encoding="utf-8"?>
    <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@drawable/dialog_gray200_background"
        >
     
      <FrameLayout
          android:id="@+id/container"
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          android:animateLayoutChanges="true"
          />
     
    </FrameLayout>

    Запускаем:

    animateLayoutChanges

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

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

    setCustomAnimations


    FragmentTransaction позволяет задать анимацию, описанную в xml-формате с помощью метода setCustomAnimation. Для этого в ресурсах создаём папку с названием «anim» и складываем туда четыре файла анимации:

    to_right_out.xml

    <?xml version="1.0" encoding="utf-8"?>
    <set xmlns:android="http://schemas.android.com/apk/res/android"
        android:duration="500"
        android:interpolator="@android:anim/accelerate_interpolator"
    >
      <translate android:toXDelta="100%" />
    </set>

    to_right_in.xml

    <?xml version="1.0" encoding="utf-8"?>
    <set xmlns:android="http://schemas.android.com/apk/res/android"
        android:duration="500"
        android:interpolator="@android:anim/accelerate_interpolator"
    >
      <translate android:fromXDelta="-100%" />
    </set>

    to_left_out.xml

    <?xml version="1.0" encoding="utf-8"?>
    <set xmlns:android="http://schemas.android.com/apk/res/android"
        android:duration="500"
        android:interpolator="@android:anim/accelerate_interpolator"
    >
      <translate android:toXDelta="-100%" />
    </set>

    to_left_in.xml

    <?xml version="1.0" encoding="utf-8"?>
    <set xmlns:android="http://schemas.android.com/apk/res/android"
        android:duration="500"
        android:interpolator="@android:anim/accelerate_interpolator"
    >
      <translate android:fromXDelta="100%" />
    </set>

    И затем устанавливаем эти анимации в транзакцию:

    fragmentManager
        .beginTransaction()
        .setCustomAnimations(R.anim.to_left_in, R.anim.to_left_out, R.anim.to_right_in, R.anim.to_right_out)
        .replace(containerId, newFragment)
        .addToBackStack(newFragment.tag)
        .commit()

    Получаем вот такой результат:


    setCustomAnimation

    Что мы имеем при такой реализации:

    • Уже стало лучше — видно как экраны сменяют друг друга в ответ на действие пользователя.
    • Но всё равно есть скачок из-за разной высоты фрагментов. Так происходит из-за того, что при переходе фрагментов в иерархии есть только один фрагмент. Именно он подстраивает высоту контейнера под себя, а второй отображается «как получилось».
    • Всё ещё есть проблема с асинхронной загрузкой данных о способах оплаты — экран появляется сначала пустым, а потом со скачком наполняется контентом.

    Это никуда не годится. Вывод: нужно что-то другое.

    А может попробуем что-то внезапное: Shared Element Transition


    Большинство Android-разработчиков знает про Shared Element Transition. Однако, хотя этот инструмент очень гибкий, многие сталкиваются с проблемами при его использовании и поэтому не очень любят применять его.


    Суть его довольно проста — мы можем анимировать переход элементов одного фрагмента в другой. Например, можем элемент на первом фрагменте (назовём его «начальным элементом») с анимацией переместить на место элемента на втором фрагменте (этот элемент назовём «конечным элементом»), при этом с фэйдом скрыть остальные элементы первого фрагмента и с фэйдом показать второй фрагмент. Элемент, который должен анимироваться с одного фрагмента на другой, называется Shared Element.

    Чтобы задать Shared Element, нам нужно:

    • пометить начальный элемент и конечный элемент атрибутом transitionName с одинаковым значением;
    • указать sharedElementEnterTransition для второго фрагмента.

    А что, если использовать корневую View фрагмента в качестве Shared Element? Возможно Shared Element Transition придумывали не для этого. Хотя если подумать, сложно найти аргумент, почему это решение не подойдёт. Мы хотим анимировать начальный элемент в конечный элемент между двумя фрагментами. Не вижу идеологического противоречия. Давайте попробуем сделать так!

    Для каждого фрагмента, который находится внутри «шторки», для корневой View указываем атрибут transitionName с одинаковым значением:

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout 
        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:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:transitionName="checkoutTransition"
        >

    Важно: это будет работать, поскольку мы используем REPLACE в транзакции фрагментов. Если вы используете ADD (или используете ADD и скрываете предыдущий фрагмент с помощью previousFragment.hide() [не надо так делать]), то transitionName придётся задавать динамически и очищать после завершения анимации. Так приходится делать, потому что в один момент времени в текущей иерархии View не может быть две View с одинаковым transitionName. Осуществить это можно, но будет лучше, если вы сможете обойтись без такого хака. Если вам всё-таки очень нужно использовать ADD, вдохновение для реализации можно найти в этой статье.

    Далее нужно указать класс Transition'а, который будет отвечать за то, как будет протекать наш переход. Для начала проверим, что есть «из коробки» — используем AutoTransition.

    newFragment.sharedElementEnterTransition = AutoTransition()

    И мы должны задать Shared Element, который хотим анимировать, в транзакции фрагментов. В нашем случае это будет корневая View фрагмента:

    fragmentManager
        .beginTransaction()
        .apply{
          if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            addSharedElement(currentFragment.requireView(), currentFragment.requireView().transitionName)
            setReorderingAllowed(true)
          }
        }
        .replace(containerId, newFragment)
        .addToBackStack(newFragment.tag)
        .commit()

    Важно: обратите внимание, что transitionName (как и весь Transition API) доступен начиная с версии Android Lollipop.

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


    AutoTransition

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

    Раз стандартная реализация нам не подошла, что нужно сделать? Конечно же, нужно переписать всё на Flutter написать свой Transition!

    Пишем свой Transition


    Transition — это класс из Transition API, который отвечает за создание анимации между двумя сценами (Scene). Основные элементы этого API:

    • Scene — это расположение элементов на экране в определённый момент времени (layout) и ViewGroup, в которой происходит анимация (sceneRoot).
    • Начальная сцена (Start Scene) — это Scene в начальный момент времени.
    • Конечная сцена (End Scene) — это Scene в конечный момент времени.
    • Transition — класс, который собирает свойства начальной и конечной сцены и создаёт аниматор для анимации между ними.

    В классе Transition мы будем использовать четыре метода:

    • fun getTransitionProperties(): Array. Данный метод должен вернуть набор свойств, которые будут анимироваться. Из этого метода нужно вернуть массив строк (ключей) в свободном виде, главное, чтобы методы captureStartValues и captureEndValues (описанные далее) записали свойства с этими ключами. Пример будет далее.
    • fun captureStartValues(transitionValues: TransitionValues). В данном методе мы получаем нужные свойства layout'а начальной сцены. Например, мы можем получить начальное расположение элементов, высоту, прозрачность и так далее.
    • fun captureEndValues(transitionValues: TransitionValues). Такой же метод, только для получения свойств layout'а конечной сцены.
    • fun createAnimator(sceneRoot: ViewGroup?, startValues: TransitionValues?, endValues: TransitionValues?): Animator?. Этот метод должен использовать свойства начальной и конечной сцены, собранные ранее, чтобы создать анимацию между этими свойствами. Обратите внимание, что если свойства между начальной и конечной сценой не поменялись, то данный метод не вызовется вовсе.

    Реализуем свой Transition за девять шагов


    1. Создаём класс, который представляет Transition.

      @TargetApi(VERSION_CODES.LOLLIPOP)
      class BottomSheetSharedTransition : Transition {
      	@Suppress("unused")
      	constructor() : super()
       
      	@Suppress("unused")
      	constructor(
          	  context: Context?,
          	   attrs: AttributeSet?
      	) : super(context, attrs)
      }
      Напоминаю, что Transition API доступен с версии Android Lollipop.
    2. Реализуем getTransitionProperties.

      Поскольку мы хотим анимировать высоту View, заведём константу PROP_HEIGHT, соответствующую этому свойству (значение может быть любым) и вернём массив с этой константой:

      companion object {
        private const val PROP_HEIGHT = "heightTransition:height"
       
        private val TransitionProperties = arrayOf(PROP_HEIGHT)
      }
       
      override fun getTransitionProperties(): Array<String> = TransitionProperties
    3. Реализуем captureStartValues.

      Нам нужно запомнить высоту той View, которая хранится в параметре transitionValues. Значение высоты нам нужно записать в поле transitionValues.values (он имеет тип Map) c ключом PROP_HEIGHT:

      override fun captureStartValues(transitionValues: TransitionValues) {
        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
      }

      Всё просто, но есть нюанс. Вспомните, что во всех случаях ранее высота контейнера резко менялась в соответствии с высотой нового фрагмента. Чтобы такого не происходило, придётся что-то придумать. Проще всего просто «прибить гвоздями» контейнер фрагментов, то есть просто задать ему константную высоту, равную текущей высоте. При этом на экране ничего не произойдёт, но при смене фрагмента высота останется той же. В итоге метод будет выглядеть следующим образом:

      override fun captureStartValues(transitionValues: TransitionValues) {
        // Запоминаем начальную высоту View...
        transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
       
        // ... и затем закрепляем высоту контейнера фрагмента
        transitionValues.view.parent
          .let { it as? View }
          ?.also { view ->
              view.updateLayoutParams<ViewGroup.LayoutParams> {
                  height = view.height
              }
          }
       
      }
    4. Реализуем captureEndValues.

      Аналогично предыдущему методу, нужно запомнить высоту View. Но не всё так просто. На предыдущем шаге мы зафиксировали высоту контейнера. Новый фрагмент по высоте может быть меньше, равным или больше предыдущего фрагмента. В первых двух случаях мы можем просто взять высоту нового фрагмента. Однако в случае, когда новый фрагмент должен занять больше места, чем старый, значение высоты будет ограничено высотой контейнера. Поэтому придётся пойти на небольшую хитрость — мы просто измерим view, чтобы определить, сколько места на самом деле ей требуется. Реализация будет выглядеть так:

      override fun captureEndValues(transitionValues: TransitionValues) {
        // Измеряем и запоминаем высоту View
        transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
      }

      И метод getViewHeight:

      private fun getViewHeight(view: View): Int {
        // Получаем ширину экрана
        val deviceWidth = getScreenWidth(view)
       
        // Попросим View измерить себя при указанной ширине экрана
        val widthMeasureSpec = MeasureSpec.makeMeasureSpec(deviceWidth, MeasureSpec.EXACTLY)
        val heightMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
       
        return view
            // измеряем
            .apply { measure(widthMeasureSpec, heightMeasureSpec) }
            // получаем измеренную высоту
            .measuredHeight
            // если View хочет занять высоту больше доступной высоты экрана, мы должны вернуть высоту экрана
            .coerceAtMost(getScreenHeight(view))
      }
       
      private fun getScreenHeight(view: View) =
        getDisplaySize(view).y - getStatusBarHeight(view.context)
       
      private fun getScreenWidth(view: View) =
        getDisplaySize(view).x
       
      private fun getDisplaySize(view: View) =
        Point().also {
          (view.context.getSystemService(
              Context.WINDOW_SERVICE
          ) as WindowManager).defaultDisplay.getSize(it)
        }
       
      private fun getStatusBarHeight(context: Context): Int =
        context.resources
            .getIdentifier("status_bar_height", "dimen", "android")
            .takeIf { resourceId -> resourceId > 0 }
            ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }
            ?: 0

      Таким образом, мы знаем начальную и конечную высоту контейнера, и теперь дело за малым — создать анимацию.
    5. Реализация анимации. Fade in.

      Начальный фрагмент нам анимировать не нужно, так как при старте транзакции он удалится из иерархии. Будем показывать конечный фрагмент с фэйдом. Добавляем метод в класс «BottomSheetSharedTransition», ничего хитрого:

      private fun prepareFadeInAnimator(view: View): Animator =
         ObjectAnimator.ofFloat(view, View.ALPHA, 0f, 1f)
       
    6. Реализация анимации. Анимация высоты.

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

      private fun prepareHeightAnimator(
          startHeight: Int,
          endHeight: Int,
          view: View
      ) = ValueAnimator.ofInt(startHeight, endHeight)
          .apply {
              val container = view.parent.let { it as View }
              
              // изменяем высоту контейнера фрагментов
              addUpdateListener { animation ->
                  container.updateLayoutParams<ViewGroup.LayoutParams> {
                      height = animation.animatedValue as Int
                  }
              }
          }

      Создаём ValueAnimator и обновляем высоту конечного фрагмента. Снова ничего сложного, но есть нюанс. Поскольку мы меняем высоту контейнера, после анимации его высота будет фиксированной. Это означает, что если фрагмент в ходе своей работы будет менять высоту, то контейнер не будет подстраиваться под это изменение. Чтобы этого избежать, по окончании анимации нужно установить высоту контейнера в значение WRAP_CONTENT. Таким образом, метод для анимации высоты контейнера будет выглядеть так:

      private fun prepareHeightAnimator(
          startHeight: Int,
          endHeight: Int,
          view: View
      ) = ValueAnimator.ofInt(startHeight, endHeight)
          .apply {
              val container = view.parent.let { it as View }
              
              // изменяем высоту контейнера фрагментов
              addUpdateListener { animation ->
                  container.updateLayoutParams<ViewGroup.LayoutParams> {
                      height = animation.animatedValue as Int
                  }
              }
              
              // окончании анимации устанавливаем высоту контейнера WRAP_CONTENT 
              doOnEnd {
                  container.updateLayoutParams<ViewGroup.LayoutParams> {
                      height = ViewGroup.LayoutParams.WRAP_CONTENT
                  }
              }
          }

      Теперь всего лишь нужно использовать аниматоры, созданные этими функциями.
    7. Реализация анимации. createAnimator.

      override fun createAnimator(
          sceneRoot: ViewGroup?,
          startValues: TransitionValues?,
          endValues: TransitionValues?
      ): Animator? {
          if (startValues == null || endValues == null) {
              return null
          }
       
          val animators = listOf<Animator>(
              prepareHeightAnimator(
                  startValues.values[PROP_HEIGHT] as Int,
                  endValues.values[PROP_HEIGHT] as Int,
                  endValues.view
              ),
              prepareFadeInAnimator(endValues.view)
          )
       
          return AnimatorSet()
              .apply {
                  interpolator = FastOutSlowInInterpolator()
                  duration = ANIMATION_DURATION
                  playTogether(animators)
              }
      }
    8. Всегда анимируем переход.

      Последний нюанс касательно реализации данного Transititon'а. Звёзды могут сойтись таким образом, что высота начального фрагмента будет точно равна высоте конечного фрагмента. Такое вполне может быть, если оба фрагмента занимают всю высоту экрана. В таком случае метод «createAnimator» не будет вызван совсем. Что же произойдёт?

      • Не будет Fade'а нового фрагмента, он просто резко появится на экране.
      • Поскольку в методе «captureStartValues» мы зафиксировали высоту контейнера, а анимации не произойдёт, высота контейнера никогда не станет равной WRAP_CONTENT.

      Неприятно, но не смертельно. Можно обойти это поведение простым трюком: нужно добавить любое значение, которое будет отличаться для начальной сцены и конечной сцены, в список свойств Transition'а. Можно просто добавить строки с разными значениями:

      companion object {
          private const val PROP_HEIGHT = "heightTransition:height"
          private const val PROP_VIEW_TYPE = "heightTransition:viewType"
       
          private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)
      }
       
      override fun getTransitionProperties(): Array<String> = TransitionProperties
       
      override fun captureStartValues(transitionValues: TransitionValues) {
          // Запоминаем начальную высоту View...
          transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
          transitionValues.values[PROP_VIEW_TYPE] = "start"
       
          // ... и затем закрепляем высоту контейнера фрагмента
          transitionValues.view.parent
              .let { it as? View }
              ?.also { view ->
                  view.updateLayoutParams<ViewGroup.LayoutParams> {
                      height = view.height
                  }
              }
       
      }
       
      override fun captureEndValues(transitionValues: TransitionValues) {
          // Измеряем и запоминаем высоту View
          transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
          transitionValues.values[PROP_VIEW_TYPE] = "end"
      }
      

      Обратите внимание, добавилось свойство «PROP_VIEW_TYPE», и в методах «captureStartValues» и «captureEndValues» записываем разные значения этого свойства. Всё, транзишн готов!
    9. Применяем Transition.

      newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()

    Асинхронная загрузка данных


    Чтобы анимация началась вовремя и выглядела хорошо, нужно просто отложить переход между фрагментами (и, соответственно, анимацию) до момента, пока данные не будут загружены. Для этого внутри фрагмента нужно вызвать метод postponeEnterTransition. По окончании долгих задач по загрузке данных не забудьте вызвать startPostponedEnterTransition. Я уверен, вы знали об этом приёме, но напомнить лишний раз не помешает.

    Всё вместе: что в итоге получилось


    С новым BottomSheetSharedTransition и использованием postponeEnterTransition при асинхронной загрузке данных у нас получилась такая анимация:

    Готовый transition

    Под спойлером готовый класс BottomSheetSharedTransition
    package com.maleev.bottomsheetanimation
     
    import android.animation.Animator
    import android.animation.AnimatorSet
    import android.animation.ObjectAnimator
    import android.animation.ValueAnimator
    import android.annotation.TargetApi
    import android.content.Context
    import android.graphics.Point
    import android.os.Build
    import android.transition.Transition
    import android.transition.TransitionValues
    import android.util.AttributeSet
    import android.view.View
    import android.view.ViewGroup
    import android.view.WindowManager
    import android.view.animation.AccelerateInterpolator
    import androidx.core.animation.doOnEnd
    import androidx.core.view.updateLayoutParams
     
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    class BottomSheetSharedTransition : Transition {
     
        @Suppress("unused")
        constructor() : super()
     
        @Suppress("unused")
        constructor(
            context: Context?,
            attrs: AttributeSet?
        ) : super(context, attrs)
     
        companion object {
            private const val PROP_HEIGHT = "heightTransition:height"
     
            // the property PROP_VIEW_TYPE is workaround that allows to run transition always
            // even if height was not changed. It's required as we should set container height
            // to WRAP_CONTENT after animation complete
            private const val PROP_VIEW_TYPE = "heightTransition:viewType"
            private const val ANIMATION_DURATION = 400L
     
            private val TransitionProperties = arrayOf(PROP_HEIGHT, PROP_VIEW_TYPE)
        }
     
        override fun getTransitionProperties(): Array<String> = TransitionProperties
     
        override fun captureStartValues(transitionValues: TransitionValues) {
            // Запоминаем начальную высоту View...
            transitionValues.values[PROP_HEIGHT] = transitionValues.view.height
            transitionValues.values[PROP_VIEW_TYPE] = "start"
     
            // ... и затем закрепляем высоту контейнера фрагмента
            transitionValues.view.parent
                .let { it as? View }
                ?.also { view ->
                    view.updateLayoutParams<ViewGroup.LayoutParams> {
                        height = view.height
                    }
                }
     
        }
     
        override fun captureEndValues(transitionValues: TransitionValues) {
            // Измеряем и запоминаем высоту View
            transitionValues.values[PROP_HEIGHT] = getViewHeight(transitionValues.view.parent as View)
            transitionValues.values[PROP_VIEW_TYPE] = "end"
        }
     
        override fun createAnimator(
            sceneRoot: ViewGroup?,
            startValues: TransitionValues?,
            endValues: TransitionValues?
        ): Animator? {
            if (startValues == null || endValues == null) {
                return null
            }
     
            val animators = listOf<Animator>(
                prepareHeightAnimator(
                    startValues.values[PROP_HEIGHT] as Int,
                    endValues.values[PROP_HEIGHT] as Int,
                    endValues.view
                ),
                prepareFadeInAnimator(endValues.view)
            )
     
            return AnimatorSet()
                .apply {
                    duration = ANIMATION_DURATION
                    playTogether(animators)
                }
        }
     
        private fun prepareFadeInAnimator(view: View): Animator =
            ObjectAnimator
                .ofFloat(view, "alpha", 0f, 1f)
                .apply { interpolator = AccelerateInterpolator() }
     
        private fun prepareHeightAnimator(
            startHeight: Int,
            endHeight: Int,
            view: View
        ) = ValueAnimator.ofInt(startHeight, endHeight)
            .apply {
                val container = view.parent.let { it as View }
     
                // изменяем высоту контейнера фрагментов
                addUpdateListener { animation ->
                    container.updateLayoutParams<ViewGroup.LayoutParams> {
                        height = animation.animatedValue as Int
                    }
                }
     
                // окончании анимации устанавливаем высоту контейнера WRAP_CONTENT
                doOnEnd {
                    container.updateLayoutParams<ViewGroup.LayoutParams> {
                        height = ViewGroup.LayoutParams.WRAP_CONTENT
                    }
                }
            }
     
        private fun getViewHeight(view: View): Int {
            // Получаем ширину экрана
            val deviceWidth = getScreenWidth(view)
     
            // Попросим View измерить себя при указанной ширине экрана
            val widthMeasureSpec =
                View.MeasureSpec.makeMeasureSpec(deviceWidth, View.MeasureSpec.EXACTLY)
            val heightMeasureSpec =
                View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
     
            return view
                // измеряем:
                .apply { measure(widthMeasureSpec, heightMeasureSpec) }
                // получаем измеренную высоту:
                .measuredHeight
                // если View хочет занять высоту больше доступной высоты экрана, мы должны вернуть высоту экрана:
                .coerceAtMost(getScreenHeight(view))
        }
     
        private fun getScreenHeight(view: View) =
            getDisplaySize(view).y - getStatusBarHeight(view.context)
     
        private fun getScreenWidth(view: View) =
            getDisplaySize(view).x
     
        private fun getDisplaySize(view: View) =
            Point().also { point ->
                view.context.getSystemService(Context.WINDOW_SERVICE)
                    .let { it as WindowManager }
                    .defaultDisplay
                    .getSize(point)
            }
     
        private fun getStatusBarHeight(context: Context): Int =
            context.resources
                .getIdentifier("status_bar_height", "dimen", "android")
                .takeIf { resourceId -> resourceId > 0 }
                ?.let { resourceId -> context.resources.getDimensionPixelSize(resourceId) }
                ?: 0
    }
    


    Когда у нас есть готовый класс Transition'а, его применение сводится к простым шагам:

    Шаг 1. При транзакции фрагмента добавляем Shared Element и устанавливаем Transition:

    private fun transitToFragment(newFragment: Fragment) {
        val currentFragmentRoot = childFragmentManager.fragments[0].requireView()
     
        childFragmentManager
            .beginTransaction()
            .apply {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                    addSharedElement(currentFragmentRoot, currentFragmentRoot.transitionName)
                    setReorderingAllowed(true)
     
                    newFragment.sharedElementEnterTransition = BottomSheetSharedTransition()
                }
            }
            .replace(R.id.container, newFragment)
            .addToBackStack(newFragment.javaClass.name)
            .commit()
    }

    Шаг 2. В разметке фрагментов (текущего фрагмента и следующего), которые должны анимироваться внутри BottomSheetDialogFragment, устанавливаем transitionName:

    <?xml version="1.0" encoding="utf-8"?>
    <androidx.constraintlayout.widget.ConstraintLayout 
        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:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:transitionName="checkoutTransition"
        >

    На этом всё, конец.

    А можно было сделать всё иначе?


    Всегда есть несколько вариантов решения проблемы. Хочу упомянуть другие возможные подходы, которые мы не попробовали:

    • Отказаться от фрагментов, использовать один фрагмент с множеством View и анимировать конкретные View. Так вы получите больший контроль над анимацией, но потеряете преимущества фрагментов: нативную поддержку навигации и готовую обработку жизненного цикла (придётся реализовывать это самостоятельно).
    • Использовать MotionLayout. Технология MotionLayout на данный момент всё ещё находится на стадии бета, но выглядит очень многообещающе, и уже есть официальные примеры, демонстрирующие красивые переходы между фрагментами.
    • Не использовать анимацию. Да, наш дизайн является частным случаем, и вы вполне можете счесть анимацию в данном случае избыточной. Вместо этого можно показывать один Bottom Sheet поверх другого или скрывать один Bottom Sheet и следом показывать другой.
    • Отказаться от Bottom Sheet совсем. Нет изменения высоты контейнера фрагментов — нет проблем.
    Демо проект можно найти вот тут на GitHub. А вакансию Android-разработчика (Нижний Новгород) вот здесь на Хабр Карьера.
    Dodo Engineering
    О том, как разработчики строят IT в Dodo

    Similar posts

    Comments 17

      +1

      Так в результате у вас осталось поведение от самого BottomSheetDialog(свайп вверх/вниз)?
      Или каждый фрагмент просто визуально косит под BottomSheetDialog?

        +1
        Поведение осталось от BottomSheetDialog. По факту, у нас получился честный BottomSheetDialogFragment, просто в него с помощью его childFragmentManager добавляются фрагменты. В теории, даже смена состояний должна работать (но я не пробовал)
          0
          а как вы применяете скругление углов? Fragment внутри fragmentContainerView расположится на всю высоту и перекроет углы боттом щита. Как то темой заставляете скруглять fragmentContainerView?
            0
            Сам FragmentContainerView всегда остаётся прозрачным. Чтобы добиться скругления углов и не терять его при анимации, можно покрасить со скруглениями корневой элемент BottomSheetDialogFragment'а и либо не красить корневой элемент дочерних фрагментов, либо установить ему background с такими же скруглёнными краями. Пример можно пощупать на гитхабе
              +1
              Получается некая привязка фрагментов к диалогу, в котором они используются. Спасибо за ответ, идея понятна =). Крутая шторка получилась, респект. В проекте единственное не хватает обработки аппаратной кнопки назад, важная вещь всё таки. Можно повесить слушатель нажатий на кнопки в диалоге и при кнопке назад вызывать на childFragmentManager popBackStack(), если кому то интересно.
        0
        Круто сделали! Без анимаций совсем не то. Но вот вопрос, с какой версии android у вас поддержка, и что делать с версиями ниже 5.0?
          +4
          Не вспоминать про них…
            +1
            Спасибо! Устройства версии ниже 5.0 поддержать будет довольно трудно, так как transition api доступен только начиная с Lollipop. Для нас это не было проблемой, потому что мы сразу решили такие устройства оставить без данной анимации. Я думаю, если появилась задача поддержать pre-lollipop, то мы использовали бы тот же подход «зафиксировать высоту, потом анимировать», только делать это пришлось бы не с помощью Shared Element Transition, а вручную до и после транзакции фрагментов
              0
              забыть про них, и поднять мин апи до 21
              +3
              private fun prepareFadeInAnimator(view: View): Animator =
              ObjectAnimator.ofFloat(view, "alpha", 0f, 1f)

              При использовании ObjectAnimator рекомендуется передавать поле не по его имени("alpha", "translationX"), а по его проперти ObjectAnimator.ofFloat(T target, Property<T, Float> property, float… values). Такой вариант избавляет от вызова сеттера этого поля через рефлексию. Так же является типобезопасным, ведь можно легко сделать опечатку "alfa" вместо View.ALPHA или "transletionX" вместо View.TRANSLATION_X.
              На примере Plaid от Ника Батчера.


              Все эти проперти так же есть и в AppCompat варианте.


              Странно конечно что в документации не говорится про такой прием, тем не менее эта тема достаточно часто вскрывалась на конференциях с участием Chet Haase и Romain Guy.

                +1
                Вы правы (только что посмотрел исходники). Не знал, что в этом случае не будет рефлексии. Спасибо за совет, поправил текст и теперь буду знать про это!
                +2

                Спасибо за статью! Идея с контейнером-шаред элементом классная, но есть пара пара замечаний по коду внутри:


                1. Не советую использовать зашитые в Android константы для определения размера статус-бара. Это плохая практика, и на различных девайсах от Meizu, Dogee, и прочих нонеймах с кастомными надстройками работать не будет. Советую работать через windowInsets, статей по этому подходу достаточно много, например здесь: https://habr.com/ru/company/surfstudio/blog/464373/
                2. В методе getScreenHeight используется только статус бар, но при этом мы забываем про высоту навигейшн-бара, из-за чего на девайсах с нижним баром навигации, контейнеры улетят вверх.
                3. Высоту и ширину экрана можно получить гораздо проще, без необходимости прибегать к системным сервисам: view.resources.displayMetrics содержит нужную инфу.
                  +1
                  Рад, что идея вам понравилась! Спасибо за обратную связь по статье и за ссылку на интересную статью про inset'ы! Позвольте пару мыслей по тем пунктам, о которых вы упомянули:

                  1. Вы абсолютно правы, что использовать insets правильнее, чем получать высоту системных отступов (в нашем случае — статус бара) вручную. Но тут есть один момент — «из коробки» BottomSheetDialog не позволяет обрабатывать inset'ы (об этом на своём митапе рассказывали ребята из Redmadrobot, вот ссылка на комментарий и митап). Это происходит из-за fitsSystemWindows=«true» у CoordinatorLayout'а в BottomSheetDialog. Я посчитал, что решение этой проблемы за рамками данной статьи, поэтому остаётся два простых варианта, как рассчитать доступную высоту — из высоты экрана вычесть либо padding'и CoordinatorLayout'а (padding'и установятся автоматически за счет fitsSystemWindows=«true»), либо высоту status bar'а. Я посчитал, что между этими вариантами разницы нет, но возможно лучше было взять использовать padding'и CoordinatorLayout'а
                  2. «В методе getScreenHeight используется только статус бар» — всё верно, так и должно быть. Метод Display.getSize(), который я использовал для получения высоты экрана, внутри себя уже убирает некоторые системные выступы из общей высоты, например, он вычитает высоту navigation бара (проверено на устройствах с navigation баром). Возможно, правильнее было бы использовать Display.getRealSize(), который возвращает полный размер экрана, и вычесть высоту и статус бара, и navigation бара (а еще правильнее, еще раз соглашусь с вами, — использовать inset'ы)
                  3. Да, можно так, это проще


                  Я мог что-то упустить, поэтому буду рад дополнительным комментариям :)
                  0
                  Очень забавно что морочитесь над всеми неочевидными мелочами, но полностью забываете про элементарный белый статус бар с черными иконками (у вас почему-то абсолютно неконтрастный серый времен пятой версии андроид образца 2014-го года)
                  Черный навбар тоже не видите? Он должен быть белый или прозрачный
                  с 10-го андроида (а на скринах стоит 11 судя по дебаг иконке в статус-баре) приложения должны рисоваться от края до края, то есть под навбаром или жестами, которые его заменили.

                  medium.com/androiddevelopers/gesture-navigation-going-edge-to-edge-812f62e4e83e

                  Странные приоритеты, хотя бы статус-бар приведите в порядок.

                  Анимации крутые.
                    +1
                    Привет! Здорово, что вы обратили на это внимание. На самом деле, приложение рисуется edge-to-edge, а навбар и статус бар покрашены, но покрашены неудачно. Статус бар уже поправлен (просто скрины делались относительно давно), а навбар пока на очереди.

                    Спасибо за ваш комментарий, рад, что вам понравилась анимация!
                    0

                    А какое решение используете для планшетов? Также BottomSheet?

                      0
                      Мы в нашем приложении не работали над поддержкой планшетов — не было необходимости

                    Only users with full accounts can post comments. Log in, please.