Animación en Android: transiciones suaves de fragmentos dentro de la hoja inferior

Se ha escrito una gran cantidad de documentación y artículos sobre un componente visual importante de las aplicaciones: la animación. A pesar de esto, pudimos meternos en problemas y encontramos inconvenientes en su implementación.



Este artículo trata sobre el problema y el análisis de opciones para su solución. No te daré una bala de plata contra todos los monstruos, pero te mostraré cómo puedes estudiar una específica para crear una bala específicamente para él. Analizaré esto usando un ejemplo de cómo hicimos que la animación de fragmentos cambiantes se hiciera amigo de la Hoja Inferior.







Diamond Checkout: Fondo



Diamond Checkout es el nombre en clave de nuestro proyecto. Su significado es muy simple: reducir el tiempo dedicado por el cliente en la última etapa de pedido. Si la versión anterior requería al menos cuatro clics en dos pantallas para realizar un pedido (y cada nueva pantalla es una posible pérdida de contexto por parte del usuario), el "pago de diamantes" idealmente requiere solo un clic en una pantalla.





Comparación de la caja antigua y la nueva



Llamamos a la nueva pantalla "cortina" entre nosotros. En la imagen puedes ver cómo recibimos la tarea de los diseñadores. Esta solución de diseño es estándar, se conoce con el nombre de Hoja inferior, que se describe en Diseño de materiales (incluido para Android) y se utiliza en diversas variaciones en muchas aplicaciones. Google nos ofrece dos opciones de implementación listas para usar: modal y persistente. La diferencia entre estos enfoques se ha descrito en muchos , muchos artículos.





Decidimos que nuestro telón sería modal y que estaría cerca de un final feliz, pero el equipo de diseño estaba en guardia y no permitió que esto sucediera tan fácilmente.



Mira qué increíbles animaciones en iOS . Hagamos lo mismo?



¡No podríamos rechazar tal desafío! De acuerdo, solo bromeaba acerca de que "los diseñadores de repente hicieron una oferta para hacer animación", pero la parte sobre iOS es cierta.



Las transiciones estándar entre pantallas (es decir, la ausencia de transiciones) parecían, aunque no demasiado torpes, pero no alcanzaban el título de "comprobación de diamantes". Aunque, a quién estoy bromeando, realmente fue terrible:





Lo que tenemos "listo para usar"



Antes de continuar con la descripción de la implementación de la animación, le diré cómo se veían las transiciones antes.



  1. El cliente hizo clic en el campo de dirección de la pizzería -> en respuesta, se abrió el fragmento "Recogida". Se abrió en pantalla completa (como estaba previsto) con un salto brusco, mientras que la lista de pizzerías apareció con un ligero retraso.
  2. Cuando el cliente presionó "Atrás" -> el regreso a la pantalla anterior ocurrió con un salto brusco.
  3. Cuando hice clic en el campo de método de pago -> desde la parte inferior, el fragmento "Método de pago" se abrió con un salto brusco. La lista de métodos de pago apareció con retraso; cuando aparecieron, la pantalla aumentó con un salto.
  4. Cuando presionas "Atrás" -> regresa con un salto brusco.


El retraso en la visualización de datos se debe al hecho de que se carga en la pantalla de forma asincrónica. Será necesario tener esto en cuenta en el futuro.



De hecho, cuál es el problema: cuando el cliente se siente bien, allí tenemos limitaciones



A los usuarios no les gusta cuando hay demasiados movimientos bruscos en la pantalla. Es molesto y confuso. Además, siempre desea ver una respuesta suave a su acción, y no convulsiones.



Esto nos llevó a una limitación técnica: decidimos que no podemos cerrar la hoja inferior actual y mostrar una nueva para cada cambio de pantalla, y sería malo mostrar varias hojas inferiores una encima de la otra. Por lo tanto, en el marco de nuestra implementación (cada pantalla es un fragmento nuevo), solo puede crear una hoja inferior, que debe moverse lo más suavemente posible en respuesta a las acciones del usuario.



Esto significa que tendremos un contenedor de fragmentos que es dinámico en altura (ya que todos los fragmentos tienen diferentes alturas), y debemos animar su cambio de altura.



Marcado preliminar



El elemento raíz de la "cortina" es muy simple: es solo un fondo rectangular con esquinas redondeadas en la parte superior y un contenedor en el que se colocan los fragmentos.



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


Y el archivo dialog_gray200_background.xml tiene este aspecto:



<?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 nueva pantalla es un fragmento separado, los fragmentos se cambian utilizando el método de reemplazo, aquí todo es estándar.



Primeros intentos de implementar animación



animateLayoutChanges



Recordemos la antigua magia élfica animada LayoutChanges , que en realidad es la LayoutTransition predeterminada. Aunque animateLayoutChanges no está diseñado para cambiar fragmentos en absoluto, se espera que esto ayude con la animación de altura. Además, FragmentContainerView no es compatible con animateLayoutChanges, por lo que lo cambiamos a 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>


Correr:



animateLayoutChanges



Como puede ver, cambiar la altura del contenedor es realmente animado al cambiar fragmentos. Ir a la pantalla de recogida se ve bien, pero el resto deja mucho que desear.



La intuición sugiere que este camino conducirá a una mirada nerviosa del diseñador, por lo que retrocedemos nuestros cambios e intentamos otra cosa.



setCustomAnimations



FragmentTransaction le permite configurar la animación descrita en formato xml utilizando el método setCustomAnimation . Para hacer esto, en los recursos, cree una carpeta llamada "anim" y agregue cuatro archivos de animación allí:



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>


Y luego configuramos estas animaciones en una transacción:



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


Obtenemos el siguiente resultado:





setCustomAnimation



Lo que tenemos con esta implementación:



  • Ya ha mejorado: puede ver cómo las pantallas se reemplazan entre sí en respuesta a la acción del usuario.
  • Pero todavía hay un salto debido a las diferentes alturas de los fragmentos. Esto se debe al hecho de que cuando mueve fragmentos en la jerarquía, solo hay un fragmento. Es él quien ajusta la altura del contenedor por sí mismo, y el segundo muestra "cómo sucedió".
  • Todavía hay un problema con la carga asincrónica de datos en los métodos de pago: la pantalla aparece en primer lugar en blanco y luego se llena de contenido.


Esto no está bien. Conclusión: necesitas algo más.



O tal vez intente algo repentino: Transición de elementos compartidos



La mayoría de los desarrolladores de Android conocen la Transición de elementos compartidos. Sin embargo, aunque esta herramienta es muy flexible, muchas personas enfrentan problemas para usarla y, por lo tanto, no les gusta mucho usarla.





Su esencia es bastante simple: podemos animar la transición de elementos de un fragmento a otro. Por ejemplo, podemos mover el elemento en el primer fragmento (llamémoslo "elemento inicial") con animación al lugar del elemento en el segundo fragmento (llamaremos a este elemento "el elemento final"), mientras desvanecemos el resto de los elementos del primer fragmento y mostramos el segundo fragmento con desvanecimiento. El elemento que necesita animarse de un fragmento a otro se llama Elemento compartido.



Para establecer el elemento compartido, necesitamos:



  • marque el elemento inicial y el elemento final con el atributo transitionName con el mismo valor;
  • especifique sharedElementEnterTransition para el segundo fragmento.


¿Qué sucede si usa la Vista raíz del fragmento como Elemento compartido? Quizás la Transición de elementos compartidos no se inventó para esto. Sin embargo, si lo piensa, es difícil encontrar un argumento por el cual esta solución no funcionará. Queremos animar el elemento inicial al elemento final entre dos fragmentos. No veo contradicción ideológica. ¡Intentemos esto!



Para cada fragmento que está dentro de la "cortina", para la Vista raíz, especifique el atributo transitionName con el mismo 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: Esto funcionará ya que estamos usando REPLACE en la transacción de fragmentos. Si está usando ADD (o está usando ADD y oculta el fragmento anterior con previousFragment.hide () [no haga esto]), debe hacer una transición dinámica de Nombre de transición y borrarla después de que finalice la animación. Esto debe hacerse, porque al mismo tiempo en la jerarquía de vistas actual no puede haber dos vistas con el mismo nombre de transición. Esto se puede hacer, pero será mejor si puede prescindir de tal truco. Si realmente necesita usar ADD, puede encontrar inspiración para la implementación en este artículo.


A continuación, debe especificar la clase Transición, que será responsable de cómo procederá nuestra transición. Primero, verifiquemos lo que está listo para usar: use AutoTransition .



newFragment.sharedElementEnterTransition = AutoTransition()


Y tenemos que establecer el elemento compartido que queremos animar en la transacción del fragmento. En nuestro caso, esta será la vista raíz del 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: Tenga en cuenta que transitionName (como toda la API de transición) está disponible a partir de Android Lollipop.


Vamos a ver que pasó:





AutoTransition



Transition funcionó, pero parece regular. Esto se debe a que durante una transacción de fragmento, solo el fragmento nuevo está en la jerarquía de Vista. Este fragmento estira o encoge el contenedor a su tamaño y solo después de eso comienza a animarse usando una transición. Es por esta razón que vemos animación solo cuando el nuevo fragmento es más alto en altura que el anterior.



Dado que la implementación estándar no nos convenía, ¿qué debemos hacer? Por supuesto, ¡debes reescribir todo en Flutter y escribir tu propia transición!



Escribiendo tu transición



Transition es una clase de la API de transición que se encarga de crear animaciones entre dos escenas (Scene). Los principales elementos de esta API:



  • Escena es la disposición de los elementos en la pantalla en un determinado momento (diseño) y el ViewGroup en el que tiene lugar la animación (sceneRoot).
  • La escena de inicio es la escena a la hora de inicio.
  • La escena final es la escena en el punto final en el tiempo.
  • Transition es una clase que recopila las propiedades de las escenas de inicio y finalización y crea un animador para animar entre ellas.


Usaremos cuatro métodos en la clase Transition:



  • diversión getTransitionProperties (): matriz. Este método debería devolver un conjunto de propiedades que se animarán. A partir de este método, debe devolver una matriz de cadenas (claves) en forma libre, lo principal es que los métodos captureStartValues ​​y captureEndValues ​​(descritos a continuación) escriben propiedades con estas claves. Un ejemplo 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 la animación comience a tiempo y se vea bien, solo necesita posponer la transición entre fragmentos (y, en consecuencia, la animación) hasta que se carguen los datos. Para hacer esto, llame al método postponeEnterTransition dentro del fragmento . Recuerde llamar a startPostponedEnterTransition cuando haya terminado con largas tareas de carga de datos . Estoy seguro de que conocías este truco, pero no está de más recordarte una vez más.



Todos juntos: lo que sucedió al final



Con el nuevo BottomSheetSharedTransition y usando postponeEnterTransition al cargar datos de forma asincrónica, obtuvimos la siguiente animación:



Transición lista



Debajo del spoiler hay una clase preparada 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
}




Cuando tenemos una clase de transición lista para usar, su aplicación se reduce a pasos simples:



Paso 1. En una transacción fragmentada, agregue un Elemento compartido y configure la Transición:



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


Paso 2. En el marcado de los fragmentos (el fragmento actual y el siguiente), que se deben animar dentro del BottomSheetDialogFragment, establezca el nombre de transición:



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


Eso es todo, el final.



¿Podría haberse hecho de manera diferente?



Siempre hay varias opciones para resolver un problema. Quiero mencionar otros posibles enfoques que no hemos probado:



  • Elimine fragmentos, use un fragmento con muchas Vistas y anime Vistas específicas. Esto le da más control sobre la animación, pero pierde los beneficios de los fragmentos: soporte de navegación nativa y manejo de ciclo de vida listo para usar (tendrá que implementar esto usted mismo).
  • MotionLayout. MotionLayout , , , .
  • . , , . Bottom Sheet Bottom Sheet .
  • Bottom Sheet . — .
GitHub. Android- ( ) .




All Articles