Animação no Android: transições suaves de fragmentos dentro da folha inferior

Uma enorme quantidade de documentação e artigos foi escrita sobre um importante componente visual dos aplicativos - a animação. Apesar disso, fomos capazes de nos meter em problemas e encontramos dificuldades na sua implementação.



Este artigo é sobre o problema e a análise de opções para sua solução. Não vou lhe dar uma bala de prata contra todos os monstros, mas vou mostrar como você pode estudar uma específica para criar uma bala especificamente para ele. Analisarei isso usando um exemplo de como fizemos com que a animação de fragmentos em mudança se tornasse amiga da Folha de Baixo.







Diamond Checkout: Histórico



Diamond Checkout é o codinome do nosso projeto. Seu significado é muito simples - reduzir o tempo gasto pelo cliente na última etapa do pedido. Se a versão antiga exigiu pelo menos quatro cliques em duas telas para fazer um pedido (e cada nova tela representa uma potencial perda de contexto pelo usuário), o "check-out de diamantes" idealmente requer apenas um clique em uma tela.





Comparação do antigo e do novo checkout



Chamamos a nova tela de "cortina" entre nós. Na figura, você pode ver como recebemos a tarefa dos designers. Esta solução de design é padrão, é conhecida sob o nome Bottom Sheet, descrito em Material Design (inclusive para Android) e é usado em várias variações em muitos aplicativos. O Google nos oferece duas opções de implementação prontas: Modal e Persistente. A diferença entre essas abordagens foi descrita em muitos e muitos artigos.





Decidimos que nossa cortina seria modal e chegaria a um final feliz, mas a equipe de design estava em guarda e não deixou isso acontecer com tanta facilidade.



Veja quais animações incríveis no iOS . Vamos fazer o mesmo?



Não poderíamos recusar esse desafio! Tudo bem, brincando sobre "os designers subitamente fizeram uma oferta para fazer animação", mas a parte do iOS é verdadeira.



As transições padrão entre telas (isto é, a ausência de transições) pareciam, embora não muito desajeitadas, mas não atingiam o título de "verificação de diamantes". Embora, quem estou brincando, foi realmente terrível:





O que temos "fora da caixa"



Antes de prosseguir com a descrição da implementação da animação, eu vou lhe dizer como as transições eram antes.



  1. O cliente clicou no campo de endereço da pizzaria -> em resposta, o fragmento "Captura" foi aberto. Ele abriu em tela cheia (como pretendido) com um salto acentuado, enquanto a lista de pizzarias apareceu com um pequeno atraso.
  2. Quando o cliente pressionou "Voltar" -> o retorno à tela anterior ocorreu com um salto acentuado.
  3. Quando cliquei no campo da forma de pagamento -> de baixo para baixo, o fragmento "Forma de pagamento" foi aberto com um salto acentuado. A lista de métodos de pagamento apareceu com um atraso; quando eles apareceram, a tela aumentou com um salto.
  4. Quando você pressiona "Voltar" -> retorna com um salto acentuado.


O atraso na exibição dos dados é causado pelo fato de eles serem carregados na tela de forma assíncrona. Será necessário levar isso em consideração no futuro.



Qual é, de fato, o problema: onde o cliente se sente bem, temos limitações



Os usuários não gostam quando há muitos movimentos bruscos na tela. É perturbador e confuso. Além disso, você sempre quer ver uma resposta suave à sua ação, e não convulsões.



Isso nos levou a uma limitação técnica: decidimos não fechar a folha de baixo atual e mostrar uma nova para cada alteração de tela, e também seria ruim mostrar várias folhas de fundo uma acima da outra. Portanto, dentro da estrutura de nossa implementação (cada tela é um novo fragmento), você pode criar apenas uma folha inferior, que deve se mover da maneira mais suave possível em resposta às ações do usuário.



Isso significa que teremos um contêiner de fragmentos que é dinâmico em altura (já que todos os fragmentos têm alturas diferentes) e devemos animar sua alteração de altura.



Marcação preliminar



O elemento raiz da "cortina" é muito simples - é apenas um fundo retangular com cantos arredondados na parte superior e um recipiente no qual os fragmentos são colocados.



<?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>


E o arquivo dialog_gray200_background.xml se parece com o seguinte:



<?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>


Cada nova tela é um fragmento separado, os fragmentos são substituídos usando o método de substituição, tudo aqui é padrão.



Primeiras tentativas de implementar animação



animateLayoutChanges



Vamos lembrar da antiga magia élfica animadaLayoutChanges , que é realmente a LayoutTransition padrão. Embora o animateLayoutChanges não tenha sido projetado para alterar fragmentos, espera-se que isso ajude na animação em altura. O FragmentContainerView também não suporta animateLayoutChanges; portanto, alteramos para o bom e antigo 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>


Corre:



animateLayoutChanges



Como você pode ver, alterar a altura do contêiner é realmente animado ao alterar fragmentos. Ir para a tela de recebimento parece bom, mas o resto deixa muito a desejar.



A intuição sugere que esse caminho levará a um olhar trêmulo do designer, então revertemos nossas alterações e tentamos outra coisa.



setCustomAnimations



FragmentTransaction permite definir a animação descrita no formato xml usando o método setCustomAnimation . Para fazer isso, crie uma pasta nos recursos chamados "anim" e adicione quatro arquivos de animação:



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>


E então configuramos essas animações em uma transação:



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()


Temos o seguinte resultado:





setCustomAnimation



O que temos com esta implementação:



  • Já ficou melhor - você pode ver como as telas se substituem em resposta à ação do usuário.
  • Mas ainda há um salto devido às diferentes alturas dos fragmentos. Isso ocorre porque, quando você move fragmentos na hierarquia, há apenas um fragmento. É ele quem ajusta a altura do contêiner para si mesmo, e o segundo exibe "como aconteceu".
  • Ainda existe um problema com o carregamento assíncrono de dados nas formas de pagamento - a tela aparece primeiro em branco e depois é preenchida com conteúdo.


Isso não é bom. Conclusão: você precisa de outra coisa.



Ou talvez tente algo repentino: Transição de elemento compartilhado



A maioria dos desenvolvedores do Android conhece a transição de elementos compartilhados. No entanto, embora essa ferramenta seja muito flexível, muitas pessoas enfrentam problemas ao usá-lo e, portanto, não gostam muito dele.





Sua essência é bastante simples - podemos animar a transição de elementos de um fragmento para outro. Por exemplo, podemos mover o elemento no primeiro fragmento (vamos chamá-lo de "elemento inicial") com animação para o local do elemento no segundo fragmento (chamaremos esse elemento de "elemento final"), enquanto desbotamos o restante dos elementos do primeiro fragmento e mostramos o segundo fragmento com desbotamento. O elemento que precisa animar de um fragmento para outro é chamado Elemento Compartilhado.



Para definir o elemento compartilhado, precisamos:



  • marque o elemento inicial e o elemento final com o atributo transiçãoName com o mesmo valor;
  • especifique sharedElementEnterTransition para o segundo pedaço.


E se você usar a Visualização raiz do fragmento como o Elemento Compartilhado? Talvez a transição de elemento compartilhado não tenha sido inventada para isso. Embora, se você pensar sobre isso, é difícil encontrar um argumento para que essa solução não funcione. Queremos animar o elemento inicial para o elemento final entre dois fragmentos. Não vejo contradição ideológica. Vamos tentar isso!



Para cada fragmento que está dentro da "cortina", para a Visualização raiz, especifique o atributo transiçãoName com o mesmo valor:



<?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"
    >


Importante: Isso funcionará quando estivermos usando REPLACE na transação do chunk. Se você estiver usando ADD (ou ADD e ocultando o snippet anterior com previousFragment.hide () [não faça isso]), o transiçãoName terá que ser definido dinamicamente e limpo após o término da animação. Isso precisa ser feito, porque em um momento da hierarquia de exibições atual não pode haver duas exibições com o mesmo nome da transição. Isso pode ser feito, mas será melhor se você puder fazer sem esse tipo de invasão. Se você realmente precisar usar o ADD, poderá encontrar inspiração para implementação neste artigo.


Em seguida, você precisa especificar a classe Transition, que será responsável por como nossa transição continuará. Primeiro, vamos verificar o que está fora da caixa - use a AutoTransition .



newFragment.sharedElementEnterTransition = AutoTransition()


E temos que definir o elemento compartilhado que queremos animar na transação do fragmento. No nosso caso, esta será a visão raiz do fragmento:



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()


Importante: observe o nome da transição (como toda a API de transição) disponível a partir do Android Lollipop.


Vamos ver o que aconteceu:





AutoTransition



Transition funcionou, mas parece mais ou menos. Isso ocorre porque durante uma transação de pedaço, apenas o novo pedaço está na hierarquia Exibir. Esse fragmento estica ou reduz o tamanho do contêiner e somente depois disso começa a animar usando uma transição. É por esse motivo que vemos animação apenas quando o novo fragmento tem uma altura mais alta que o anterior.



Como a implementação padrão não nos convinha, o que devemos fazer? Obviamente, você precisa reescrever tudo no Flutter e escrever sua própria transição!



Escrevendo sua transição



Transition é uma classe da API de transição responsável pela criação de animação entre duas cenas (Scene). Os principais elementos desta API:



  • Scene é a organização dos elementos na tela em um determinado momento (layout) e o ViewGroup no qual a animação ocorre (sceneRoot).
  • A cena inicial é a cena na hora de início.
  • A cena final é a cena no ponto final no tempo.
  • Transition é uma classe que coleta as propriedades das cenas inicial e final e cria um animador para animar entre elas.


Usaremos quatro métodos na classe Transition:



  • divertido getTransitionProperties (): matriz. Este método deve retornar um conjunto de propriedades que serão animadas. A partir desse método, você precisa retornar uma matriz de strings (chaves) de forma livre, o principal é que os métodos captureStartValues ​​e captureEndValues ​​(descritos abaixo) gravam propriedades com essas chaves. Um exemplo a seguir.
  • 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()




Para que a animação comece a tempo e tenha uma boa aparência, basta adiar a transição entre os fragmentos (e, consequentemente, a animação) até que os dados sejam carregados. Para fazer isso, chame o método postponeEnterTransition dentro do fragmento . Lembre-se de ligar para startPostponedEnterTransition ao concluir tarefas longas de carregamento de dados . Tenho certeza que você sabia sobre esse truque, mas não faz mal lembrá-lo mais uma vez.



Todos juntos: o que aconteceu no final



Com o novo BottomSheetSharedTransition e usando postponeEnterTransition ao carregar dados de forma assíncrona, obtivemos a seguinte animação:



Transição pronta



Sob o spoiler, há uma classe pronta 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
}




Quando temos uma classe de transição pronta, seu aplicativo se resume a etapas simples:



Etapa 1. Em uma transação de fragmento, adicione um elemento compartilhado e defina a transição:



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()
}


Etapa 2. Na marcação dos fragmentos (o fragmento atual e o próximo), que devem ser animados dentro do BottomSheetDialogFragment, defina o nome da transição:



<?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"
    >


Isso é tudo, o fim.



Poderia ter sido feito de maneira diferente?



Sempre existem várias opções para resolver um problema. Quero mencionar outras abordagens possíveis que não tentamos:



  • Vala de fragmentos, use um fragmento com muitas vistas e animar vistas específicas. Isso lhe dá mais controle sobre a animação, mas você perde os benefícios dos fragmentos: suporte à navegação nativa e manipulação imediata do ciclo de vida (você precisa implementar isso sozinho).
  • MotionLayout. MotionLayout , , , .
  • . , , . Bottom Sheet Bottom Sheet .
  • Bottom Sheet . — .
GitHub. Android- ( ) .




All Articles